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 => va.atanh(),
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 d = va * va + vb * vb;
3450                    grad_expr(a, x, seed * vb / d, grad);
3451                    grad_expr(b, x, -seed * va / d, 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 => 1.0 / (va * std::f64::consts::LN_10),
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 => 1.0 / (1.0 + va * va),
3483                UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
3484                UnaryOp::Sinh => va.cosh(),
3485                UnaryOp::Cosh => va.sinh(),
3486                UnaryOp::Tanh => {
3487                    let t = va.tanh();
3488                    1.0 - t * t
3489                }
3490                UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
3491                UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
3492                UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
3493                UnaryOp::Atanh => 1.0 / (1.0 - va * va),
3494                UnaryOp::Erf => crate::nl_tape::erf_d1(va),
3495                UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
3496            };
3497            grad_expr(a, x, seed * d, grad);
3498        }
3499        Expr::Sum(args) => {
3500            for arg in args {
3501                grad_expr(arg, x, seed, grad);
3502            }
3503        }
3504        // min/max are piecewise linear: the seed flows only through the
3505        // currently-active (smallest / largest) operand — a subgradient.
3506        // Ties resolve to the first such operand. Empty list: no operand,
3507        // no derivative (matches the ±inf eval fold).
3508        Expr::MinList(args) => {
3509            if let Some(k) = argmin_argmax(args, x, true) {
3510                grad_expr(&args[k], x, seed, grad);
3511            }
3512        }
3513        Expr::MaxList(args) => {
3514            if let Some(k) = argmin_argmax(args, x, false) {
3515                grad_expr(&args[k], x, seed, grad);
3516            }
3517        }
3518        // Comparisons and logical connectives are piecewise constant:
3519        // zero derivative, so no seed propagates into their operands.
3520        Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3521        // if-then-else: differentiate only the active branch. The
3522        // branch-switch discontinuity contributes no derivative.
3523        Expr::Cond { cond, then_, else_ } => {
3524            if eval_expr(cond, x) != 0.0 {
3525                grad_expr(then_, x, seed, grad);
3526            } else {
3527                grad_expr(else_, x, seed, grad);
3528            }
3529        }
3530        Expr::Cse(body) => grad_expr(body, x, seed, grad),
3531        Expr::Funcall { .. } => {
3532            panic!("grad_expr: AMPL imported function called without an external resolver")
3533        }
3534    }
3535}
3536
3537/// Walk `e` and insert every `Var(i)` index into `out`.
3538///
3539/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3540/// identity. Without that this is Θ(2^depth) on a DAG that shares
3541/// subexpressions — each reference re-walks the whole body — and presolve
3542/// calls this on every solve (`get_variables_linearity`). Skipping a
3543/// repeat visit cannot change the answer: `out` is a set, and a second
3544/// walk of the same body inserts exactly the indices the first already did.
3545/// [`collect_vars`] over a `V` body whose earlier `Cse` references already
3546/// have their variable sets in `cse_vars` (indexed through `index`): those are
3547/// read, not re-walked (gh#986 review). The recursion is through `e`'s own
3548/// syntax only, which the parser has just built on this stack.
3549fn collect_vars_seeded(
3550    e: &Expr,
3551    out: &mut BTreeSet<usize>,
3552    index: &std::collections::HashMap<*const Expr, usize>,
3553    cse_vars: &[Vec<u32>],
3554) {
3555    let mut kids: Vec<&Expr> = vec![e];
3556    while let Some(e) = kids.pop() {
3557        match e {
3558            Expr::Const(_) => {}
3559            Expr::Var(i) => {
3560                out.insert(*i);
3561            }
3562            Expr::Cse(body) => match index.get(&Arc::as_ptr(body)) {
3563                Some(&k) => out.extend(cse_vars[k].iter().map(|&v| v as usize)),
3564                None => collect_vars(body, out),
3565            },
3566            Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3567                kids.push(a);
3568                kids.push(b);
3569            }
3570            Expr::Unary(_, a) | Expr::Not(a) => kids.push(a),
3571            Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => kids.extend(args.iter()),
3572            Expr::Cond { cond, then_, else_ } => {
3573                kids.push(cond);
3574                kids.push(then_);
3575                kids.push(else_);
3576            }
3577            Expr::Funcall { args, .. } => {
3578                for a in args {
3579                    if let FuncallArg::Real(e) = a {
3580                        kids.push(e);
3581                    }
3582                }
3583            }
3584        }
3585    }
3586}
3587
3588pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3589    // `HashSet::new` does not allocate until the first insert, so an
3590    // expression with no CSEs pays nothing for the memo.
3591    let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3592    collect_vars_memo(e, out, &mut seen);
3593}
3594
3595fn collect_vars_memo(
3596    e: &Expr,
3597    out: &mut BTreeSet<usize>,
3598    seen: &mut std::collections::HashSet<*const Expr>,
3599) {
3600    match e {
3601        Expr::Const(_) => {}
3602        Expr::Var(i) => {
3603            out.insert(*i);
3604        }
3605        Expr::Binary(_, a, b) => {
3606            collect_vars_memo(a, out, seen);
3607            collect_vars_memo(b, out, seen);
3608        }
3609        Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3610        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3611            for a in args {
3612                collect_vars_memo(a, out, seen);
3613            }
3614        }
3615        // Collect from every child, including the condition: even
3616        // though the comparison/branch-test contributes no derivative,
3617        // the variables it reads are genuinely "used" by the problem,
3618        // and being conservative here only ever adds structural zeros
3619        // to the Jacobian/Hessian (never drops a real nonzero).
3620        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3621            collect_vars_memo(a, out, seen);
3622            collect_vars_memo(b, out, seen);
3623        }
3624        Expr::Not(a) => collect_vars_memo(a, out, seen),
3625        Expr::Cond { cond, then_, else_ } => {
3626            collect_vars_memo(cond, out, seen);
3627            collect_vars_memo(then_, out, seen);
3628            collect_vars_memo(else_, out, seen);
3629        }
3630        Expr::Cse(body) => {
3631            if seen.insert(Arc::as_ptr(body)) {
3632                collect_vars_memo(body, out, seen);
3633            }
3634        }
3635        Expr::Funcall { args, .. } => {
3636            for a in args {
3637                if let FuncallArg::Real(e) = a {
3638                    collect_vars_memo(e, out, seen);
3639                }
3640            }
3641        }
3642    }
3643}
3644
3645// --------------------------------------------------------------------
3646// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3647// Jacobian, and Hessian. Built once at construction; every solve-time
3648// callback is a tape sweep, no expression-tree recursion.
3649// --------------------------------------------------------------------
3650
3651/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3652/// After a directional Hessian-vector product `compressed = H · s_c`,
3653/// the entry at row `row` came uniquely from column `col` (because
3654/// no two columns of color `c` share any nonzero row), so we
3655/// scatter `compressed[row]` into `values[hess_idx]`.
3656#[derive(Debug, Clone)]
3657struct ColorWrite {
3658    row: u32,
3659    hess_idx: u32,
3660}
3661
3662/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3663/// **shared prelude** holding every CSE body referenced by two or more
3664/// summands, evaluated once per sweep.
3665///
3666/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3667/// defined variable (`V` segment) referenced from many rows is re-emitted —
3668/// and re-evaluated — once per reference. That is invisible on most models
3669/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3670/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3671/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3672/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3673/// search. See pounce#476.
3674///
3675/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3676/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3677/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3678/// overhead the flat tapes do not, so each derivative order has to earn
3679/// its switch.
3680#[derive(Debug, Clone)]
3681struct ConHybrid {
3682    tape: HybridTape,
3683    /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3684    /// Length `m + 1`.
3685    row_start: Vec<usize>,
3686    /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3687    prelude_vals: Vec<f64>,
3688    /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3689    local_vals: Vec<f64>,
3690    /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3691    /// value arenas above. `gradient_summand` zeroes only the slots a
3692    /// summand actually reaches, so these are allocated once and reused.
3693    local_adj: Vec<f64>,
3694    prelude_adj: Vec<f64>,
3695    /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3696    /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3697    /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3698    use_for_jac: bool,
3699    /// Whether `eval_h` routes the constraint block through the shared
3700    /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3701    use_for_hess: bool,
3702    // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3703    // `try_new` after the Hessian coloring exists; cheap enough (a few
3704    // index tables plus one f64 per local op) to build whenever the
3705    // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3706    /// Forward values of every summand, packed at
3707    /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3708    /// fills it; every color then reuses the values, mirroring the flat
3709    /// path's forward-once-per-tape structure.
3710    local_vals_all: Vec<f64>,
3711    /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3712    local_off: Vec<usize>,
3713    /// Constraint row of each summand (the inverse of `row_start`), for
3714    /// the `λ[row]` weight lookup.
3715    summand_row: Vec<u32>,
3716    /// Per color: the summands whose variables fall in that color — the
3717    /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3718    /// exactly the live (color, summand) pairs.
3719    hess_color_summands: Vec<Vec<u32>>,
3720    /// Per color: the prelude slots that color's summands actually reach,
3721    /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3722    /// Both prelude sweeps run once per color, so walking the whole
3723    /// prelude each time would cost `n_colors × |prelude|` where the
3724    /// op-ratio gate assumes `|prelude|`; iterating the union of the
3725    /// color's `prelude_reach` sets makes the cost proportional to what
3726    /// is used, so the gate does not need an `n_colors` term. Stored as
3727    /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3728    /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3729    /// this struct is the one #552 made O(n²) by holding per-color dense
3730    /// arrays.
3731    hess_color_reach: Vec<u32>,
3732    hess_color_reach_off: Vec<usize>,
3733    /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3734    prelude_dot: Vec<f64>,
3735    /// First-/second-order prelude adjoint accumulators. NOT shared
3736    /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3737    /// between-colors invariant (`prelude_reverse_directional`'s
3738    /// consume-and-zero contract), while `gradient_summand` zeroes only
3739    /// the slots it is about to use and leaves them dirty afterwards —
3740    /// sharing the buffer would seed a later `eval_h` with a stale
3741    /// Jacobian adjoint.
3742    hess_prelude_adj: Vec<f64>,
3743    prelude_adj_dot: Vec<f64>,
3744    /// Local tangent / second-order adjoint arenas, sized to
3745    /// `tape.max_summand_ops()`.
3746    local_dot: Vec<f64>,
3747    local_adj_dot: Vec<f64>,
3748}
3749
3750/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3751/// shared-CSE prelude.
3752///
3753/// `eval_g` takes the hybrid path unconditionally because it only needs
3754/// *values*: the prelude is swept once for the whole constraint block and
3755/// the saving is the full op-count ratio. The Jacobian is different. Each
3756/// row needs its own gradient, so only the forward sweep can be shared —
3757/// the reverse sweep still walks each summand's `prelude_reach`
3758/// separately, and it pays a per-op cost the flat tape does not: a nested
3759/// `SummandOp` dispatch and an indirected walk over a reach list instead
3760/// of a straight loop over a contiguous `Vec<TapeOp>`.
3761///
3762/// So the hybrid Jacobian wins only when the shared bodies are large
3763/// enough for the halved forward sweep to outweigh that overhead.
3764/// Measured on chain models at CSE redundancy 40, varying the body size
3765/// (`eval_jac_g`, flat → hybrid):
3766///
3767/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3768/// |---|---|---|---|---|---|---|---|---|
3769/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3770///
3771/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3772/// a model that does not clearly benefit stays on the flat path. For
3773/// reference `robot_a` (#476) measures 4.03×.
3774const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3775
3776/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3777/// block through the shared-CSE prelude (issue #557).
3778///
3779/// The Hessian shares **both** second-order sweeps of the prelude, not just
3780/// the forward one: the coloring hands every summand of a color the same
3781/// seed vector, so the prelude forward tangent runs once per color, and —
3782/// because reverse-over-tangent is linear in its adjoint seeds — the
3783/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3784/// unit-weight prelude reverse sweep per color. That is why its crossover
3785/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3786/// Jacobian can only share the forward half, and per-row gradients forbid
3787/// batching its reverse sweeps at all.
3788///
3789/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3790/// bodies), varying the body size — the same protocol as the Jacobian
3791/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3792/// flat/hybrid pairs per point**, with the observed range, because
3793/// single runs on a shared machine are not reproducible to the precision
3794/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3795/// single ratio:
3796///
3797/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3798/// |---|---|---|---|---|---|---|---|---|
3799/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3800/// | 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 |
3801///
3802/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3803/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3804/// break-even and individual runs lose. Setting it there costs only a
3805/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3806/// bit-identically, so a gate placed too high is merely conservative while
3807/// one placed too low risks a real regression. For reference `robot_a`
3808/// (#476) measures 4.03×.
3809const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3810
3811// `Clone` supports the batched-solve path (pounce#126): one parsed
3812// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3813// the clone is cheap relative to a solve) and each clone gets its own
3814// bound / starting-point overrides via [`NlTnlp::variant`].
3815#[derive(Debug, Clone)]
3816pub struct NlTnlp {
3817    prob: NlProblem,
3818    /// Per-summand objective tapes (one `Tape` per top-level
3819    /// summand after `split_top_sums`).
3820    obj_tapes: Vec<Tape>,
3821    /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3822    /// one `Tape` per summand of constraint `i`.
3823    con_tapes: Vec<Vec<Tape>>,
3824    /// Constraint-block tape with a **shared** CSE prelude, used by
3825    /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3826    /// `eval_g` on the per-summand `con_tapes` above.
3827    con_hybrid: Option<ConHybrid>,
3828    /// The degree-≤2 objective and rows, evaluated from their constant
3829    /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3830    /// has an **empty** `con_tapes` entry, so every loop over the tapes
3831    /// naturally contributes nothing for it and only the sites that consult
3832    /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3833    /// `eval_h`, and `hessian_vector_products` — the last of which the design
3834    /// note's list of five omitted, and which is the one where an omission
3835    /// would be silent. Empty for a model with nothing recognized, in which
3836    /// case everything below behaves bit for bit as it did before this
3837    /// existed.
3838    quad: QuadraticStructure,
3839    /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3840    /// Empty when `quad` is — the pattern is then all tape. The coloring is
3841    /// built over this subset alone, which is what stops one dense quadratic
3842    /// block from forcing the color count to `n` for the benefit of tapes
3843    /// that no longer exist.
3844    h_tape_mask: Vec<bool>,
3845    /// Lower-triangle Hessian sparsity (row >= col), one entry per
3846    /// structurally nonzero second derivative in the Lagrangian.
3847    h_irow: Vec<i32>,
3848    h_jcol: Vec<i32>,
3849    /// Per-row sorted variable indices for the constraint Jacobian.
3850    jac_cols: Vec<Vec<usize>>,
3851    jac_nnz: usize,
3852    /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3853    /// is in color `c`, else `0.0`. Each color is a set of
3854    /// variables whose Hessian columns have pairwise-disjoint
3855    /// nonzero rows; one directional H·s product per color
3856    /// recovers all those columns simultaneously. Dense for
3857    /// O(1) lookup in the per-op forward tangent.
3858    seeds: Vec<Vec<f64>>,
3859    /// Per-color decoding table: for each `(row, hess_idx)` entry,
3860    /// scatter `compressed_c[row] -> values[hess_idx]` after the
3861    /// per-color directional product.
3862    decoding: Vec<Vec<ColorWrite>>,
3863    /// For each objective tape: the distinct colors of vars it
3864    /// references. Lets us skip tape × color pairs where the tape
3865    /// has zero overlap with the color's seed.
3866    obj_tape_colors: Vec<Vec<u32>>,
3867    /// Same as `obj_tape_colors` but per constraint × summand.
3868    con_tape_colors: Vec<Vec<Vec<u32>>>,
3869    /// The same work, color-major, for the parallel flat Hessian path.
3870    hess_color_index: HessColorIndex,
3871    /// Offset of each row's run in the Jacobian `values` array, length
3872    /// `m + 1`. Lets a parallel row group take one contiguous slice.
3873    jac_row_off: Vec<u32>,
3874    /// Per-worker scratch for the parallel Jacobian, kept across evaluations:
3875    /// each holds a length-`n` gradient row, too big to allocate per call.
3876    #[cfg(not(target_arch = "wasm32"))]
3877    jac_scratch_pool: Vec<JacScratch>,
3878    /// Color of each variable's Hessian column, `u32::MAX` for a column
3879    /// that needs no pass of its own. Kept so
3880    /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3881    /// pass in `compressed`.
3882    var_color: Vec<u32>,
3883    /// Columns peeled out of the conflict structure and given singleton
3884    /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3885    peeled_cols: Vec<u32>,
3886    final_x: Option<Vec<Number>>,
3887    final_obj: Number,
3888    /// Converged constraint multipliers (length `m`, original `.nl` row
3889    /// order, user convention), captured from the same `finalize_solution`
3890    /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3891    /// block without re-deriving it from the algorithm's internal `y_c` /
3892    /// `y_d` split and scaling.
3893    final_lambda: Option<Vec<Number>>,
3894    /// Converged bound multipliers (length `n` each, Ipopt's internal
3895    /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3896    /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3897    /// reads for reduced costs.
3898    final_z_l: Option<Vec<Number>>,
3899    final_z_u: Option<Vec<Number>>,
3900    /// Per-row Jacobian accumulator (length n).
3901    scratch_row_grad: Vec<f64>,
3902    /// Scratch buffers for `Tape::hessian_directional` (each sized
3903    /// to `max_tape_n`).
3904    vals_scratch: Vec<f64>,
3905    dot_scratch: Vec<f64>,
3906    adj_scratch: Vec<f64>,
3907    adj_dot_scratch: Vec<f64>,
3908    /// Per-color compressed Hessian-vector results, sized to
3909    /// `prob.n`. Reused across `eval_h` calls but allocated once.
3910    compressed: Vec<Vec<f64>>,
3911    /// Per-direction "carries signal" mask for `hessian_vector_products`,
3912    /// kept here rather than allocated per call. This crate holds an
3913    /// explicit no-per-call-allocation line on the tape sweeps (see
3914    /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3915    /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3916    /// Krylov iteration.
3917    hvp_live: Vec<bool>,
3918    /// Model-derived scaling factors (gh #703), computed on demand by
3919    /// [`NlTnlp::enable_curvature_scaling`] and served through
3920    /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3921    /// the state every solve that does not select `curvature-based` stays
3922    /// in — computing them costs a pass over every stored Hessian entry
3923    /// and nothing else reads them.
3924    curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3925}
3926
3927// ---------------------------------------------------------------------
3928// Human-readable equation rendering (`print equation` in the debugger).
3929//
3930// Turns a parsed constraint back into infix text using the model's
3931// variable / constraint names, so the debugger can show the actual
3932// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3933// bare row index. This is the "print the specific equation, with
3934// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3935// argue makes equation-oriented model diagnostics actionable.
3936//
3937// The renderer is intentionally separate from the evaluation `Tape`:
3938// tapes are lossy for display (CSEs flattened, externals opaque),
3939// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3940// ---------------------------------------------------------------------
3941
3942/// Binding strength for parenthesization. Higher binds tighter.
3943const P_ADD: u8 = 10;
3944const P_MUL: u8 = 20;
3945const P_NEG: u8 = 30;
3946const P_POW: u8 = 40;
3947const P_ATOM: u8 = 100;
3948
3949/// Format a numeric literal compactly: integers without a trailing `.0`,
3950/// everything else via the shortest round-tripping `f64` form.
3951fn fmt_num(x: Number) -> String {
3952    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3953        format!("{}", x as i64)
3954    } else {
3955        format!("{x}")
3956    }
3957}
3958
3959/// Display label for variable `i`: its `.col` name when present, else
3960/// `x[i]`.
3961fn var_label(i: usize, var_names: &[String]) -> String {
3962    match var_names.get(i) {
3963        Some(s) if !s.is_empty() => s.clone(),
3964        _ => format!("x[{i}]"),
3965    }
3966}
3967
3968/// Precedence of an expression's top operator (for child wrapping).
3969fn expr_prec(e: &Expr) -> u8 {
3970    match e {
3971        Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3972        Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3973        Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3974        Expr::Binary(BinOp::Pow, ..) => P_POW,
3975        Expr::Cse(inner) => expr_prec(inner),
3976        // Everything else renders as an atom / `f(...)` form.
3977        _ => P_ATOM,
3978    }
3979}
3980
3981/// Render an expression as infix text, using `var_names` for variable
3982/// labels where available (`x[i]` otherwise).
3983///
3984/// The debugger reaches the renderer through the constraint/objective
3985/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3986/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3987/// able to *see* the expression you just built is most of the debugging
3988/// story.
3989///
3990/// `Cse` bodies are inlined at every occurrence, so the output of a
3991/// heavily-shared DAG can be far larger than the DAG itself. Callers
3992/// rendering user-built expressions should bound the input first.
3993pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3994    render_expr(e, var_names, &[])
3995}
3996
3997/// Render `e`, wrapping in parentheses iff its precedence is looser than
3998/// `min_prec`.
3999fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
4000    let s = render_expr(e, vn, funcs);
4001    if expr_prec(e) < min_prec {
4002        format!("({s})")
4003    } else {
4004        s
4005    }
4006}
4007
4008fn unary_name(op: UnaryOp) -> &'static str {
4009    match op {
4010        UnaryOp::Neg => "-",
4011        UnaryOp::Sqrt => "sqrt",
4012        UnaryOp::Log => "log",
4013        UnaryOp::Exp => "exp",
4014        UnaryOp::Abs => "abs",
4015        UnaryOp::Sin => "sin",
4016        UnaryOp::Cos => "cos",
4017        UnaryOp::Log10 => "log10",
4018        UnaryOp::Tan => "tan",
4019        UnaryOp::Atan => "atan",
4020        UnaryOp::Acos => "acos",
4021        UnaryOp::Sinh => "sinh",
4022        UnaryOp::Cosh => "cosh",
4023        UnaryOp::Tanh => "tanh",
4024        UnaryOp::Asin => "asin",
4025        UnaryOp::Acosh => "acosh",
4026        UnaryOp::Asinh => "asinh",
4027        UnaryOp::Atanh => "atanh",
4028        UnaryOp::Erf => "erf",
4029        // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
4030        // the rendered text is read by humans debugging a model and must not
4031        // imply a sign the op does not have.
4032        UnaryOp::XLogX => "xlogx",
4033    }
4034}
4035
4036fn cmp_sym(op: CmpOp) -> &'static str {
4037    match op {
4038        CmpOp::Lt => "<",
4039        CmpOp::Le => "<=",
4040        CmpOp::Eq => "==",
4041        CmpOp::Ge => ">=",
4042        CmpOp::Gt => ">",
4043        CmpOp::Ne => "!=",
4044    }
4045}
4046
4047/// Append an additive sub-term with a tidy sign: a rendered term that
4048/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
4049/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
4050/// the term is itself a sum. The first term is emitted verbatim.
4051fn push_additive(out: &mut String, rendered: &str, first: bool) {
4052    if first {
4053        out.push_str(rendered);
4054    } else if let Some(rest) = rendered.strip_prefix('-') {
4055        out.push_str(" - ");
4056        out.push_str(rest);
4057    } else {
4058        out.push_str(" + ");
4059        out.push_str(rendered);
4060    }
4061}
4062
4063/// Render an [`Expr`] DAG to infix text using model names.
4064fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
4065    match e {
4066        Expr::Const(c) => fmt_num(*c),
4067        Expr::Var(i) => var_label(*i, vn),
4068        Expr::Binary(op, l, r) => match op {
4069            BinOp::Add => {
4070                let mut s = render_prec(l, P_ADD, vn, funcs);
4071                push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
4072                s
4073            }
4074            // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
4075            BinOp::Sub => format!(
4076                "{} - {}",
4077                render_prec(l, P_ADD, vn, funcs),
4078                render_prec(r, P_ADD + 1, vn, funcs)
4079            ),
4080            BinOp::Mul => format!(
4081                "{}*{}",
4082                render_prec(l, P_MUL, vn, funcs),
4083                render_prec(r, P_MUL, vn, funcs)
4084            ),
4085            BinOp::Div => format!(
4086                "{}/{}",
4087                render_prec(l, P_MUL, vn, funcs),
4088                render_prec(r, P_MUL + 1, vn, funcs)
4089            ),
4090            // Pow is right-associative: tighten the left operand instead.
4091            BinOp::Pow => format!(
4092                "{}^{}",
4093                render_prec(l, P_POW + 1, vn, funcs),
4094                render_prec(r, P_POW, vn, funcs)
4095            ),
4096            BinOp::Atan2 => format!(
4097                "atan2({}, {})",
4098                render_expr(l, vn, funcs),
4099                render_expr(r, vn, funcs)
4100            ),
4101            BinOp::CEntropy => format!(
4102                "centropy({}, {})",
4103                render_expr(l, vn, funcs),
4104                render_expr(r, vn, funcs)
4105            ),
4106        },
4107        Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
4108        Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
4109        Expr::Sum(xs) => {
4110            if xs.is_empty() {
4111                "0".to_string()
4112            } else {
4113                let mut s = String::new();
4114                for (k, x) in xs.iter().enumerate() {
4115                    push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
4116                }
4117                s
4118            }
4119        }
4120        Expr::Cse(inner) => render_expr(inner, vn, funcs),
4121        Expr::Funcall { id, args } => {
4122            let name = funcs
4123                .iter()
4124                .find(|f| f.id == *id)
4125                .map(|f| f.name.clone())
4126                .unwrap_or_else(|| format!("extern#{id}"));
4127            let parts: Vec<String> = args
4128                .iter()
4129                .map(|a| match a {
4130                    FuncallArg::Real(x) => render_expr(x, vn, funcs),
4131                    FuncallArg::Str(s) => format!("{s:?}"),
4132                })
4133                .collect();
4134            format!("{name}({})", parts.join(", "))
4135        }
4136        Expr::Compare(op, a, b) => format!(
4137            "({} {} {})",
4138            render_expr(a, vn, funcs),
4139            cmp_sym(*op),
4140            render_expr(b, vn, funcs)
4141        ),
4142        Expr::And(a, b) => format!(
4143            "({} && {})",
4144            render_expr(a, vn, funcs),
4145            render_expr(b, vn, funcs)
4146        ),
4147        Expr::Or(a, b) => format!(
4148            "({} || {})",
4149            render_expr(a, vn, funcs),
4150            render_expr(b, vn, funcs)
4151        ),
4152        Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
4153        Expr::Cond { cond, then_, else_ } => format!(
4154            "if({}, {}, {})",
4155            render_expr(cond, vn, funcs),
4156            render_expr(then_, vn, funcs),
4157            render_expr(else_, vn, funcs)
4158        ),
4159        Expr::MinList(xs) => format!(
4160            "min({})",
4161            xs.iter()
4162                .map(|x| render_expr(x, vn, funcs))
4163                .collect::<Vec<_>>()
4164                .join(", ")
4165        ),
4166        Expr::MaxList(xs) => format!(
4167            "max({})",
4168            xs.iter()
4169                .map(|x| render_expr(x, vn, funcs))
4170                .collect::<Vec<_>>()
4171                .join(", ")
4172        ),
4173    }
4174}
4175
4176/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
4177/// `a + -2*b`). Returns `""` when there are no linear terms.
4178fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
4179    let mut out = String::new();
4180    // The `.nl` linear part carries an entry for every variable in the
4181    // row's Jacobian, including a 0 coefficient for variables that appear
4182    // only *nonlinearly* (they're rendered in the nonlinear part). Skip
4183    // those zeros so the equation reads as written, not as a sparsity map.
4184    let mut first = true;
4185    for (var, coef) in linear {
4186        if *coef == 0.0 {
4187            continue;
4188        }
4189        let neg = *coef < 0.0;
4190        let mag = coef.abs();
4191        let term = if mag == 1.0 {
4192            var_label(*var, vn)
4193        } else {
4194            format!("{}*{}", fmt_num(mag), var_label(*var, vn))
4195        };
4196        if first {
4197            if neg {
4198                out.push('-');
4199            }
4200            out.push_str(&term);
4201            first = false;
4202        } else {
4203            out.push_str(if neg { " - " } else { " + " });
4204            out.push_str(&term);
4205        }
4206    }
4207    out
4208}
4209
4210/// Render the constraint body (linear + nonlinear parts combined).
4211fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
4212    let mut s = render_linear(linear, &prob.var_names);
4213    let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
4214    if !nl_is_zero {
4215        let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
4216        if s.is_empty() {
4217            s = nl;
4218        } else {
4219            push_additive(&mut s, &nl, false);
4220        }
4221    }
4222    if s.is_empty() {
4223        s = "0".to_string();
4224    }
4225    s
4226}
4227
4228/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
4229/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
4230/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
4231pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
4232    // Diagnostic path: a recognized body has no tree, so this is one of
4233    // the places that pays to rebuild one. It runs once per rendered row.
4234    let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
4235    let lo = prob.g_l[k];
4236    let hi = prob.g_u[k];
4237    const INF: Number = 1.0e19;
4238    let has_lo = lo > -INF;
4239    let has_hi = hi < INF;
4240    match (has_lo, has_hi) {
4241        (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
4242        (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
4243        (true, false) => format!("{body} >= {}", fmt_num(lo)),
4244        (false, true) => format!("{body} <= {}", fmt_num(hi)),
4245        (false, false) => format!("{body}  (free)"),
4246    }
4247}
4248
4249/// Render every constraint to text, index-aligned to `g` (original `.nl`
4250/// row order). Used to build the debugger's static equation book.
4251pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
4252    (0..prob.m)
4253        .map(|k| render_constraint_equation(prob, k))
4254        .collect()
4255}
4256
4257/// Structural sparsity of the constraint Jacobian as flat 0-based
4258/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
4259/// structurally depends on — the union of its linear support and the
4260/// `Var(i)` indices appearing anywhere in its nonlinear tree
4261/// ([`collect_vars`]). Sorted and deduplicated within each row.
4262///
4263/// This is the input to the debugger's Dulmage–Mendelsohn
4264/// structural-rank check (`diagnose`), which names the over-determined
4265/// (candidate redundant / inconsistent) equations and under-determined
4266/// variables. Naming the dependent rows — rather than reporting
4267/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
4268/// equation-oriented model debugging. See
4269/// <https://doi.org/10.69997/sct.147875>.
4270pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
4271    let mut irow: Vec<Index> = Vec::new();
4272    let mut jcol: Vec<Index> = Vec::new();
4273    let mut support: BTreeSet<usize> = BTreeSet::new();
4274    for k in 0..prob.m {
4275        support.clear();
4276        for &(j, _coef) in &prob.con_linear[k] {
4277            support.insert(j);
4278        }
4279        prob.con_nonlinear[k].collect_vars(&mut support);
4280        for &j in &support {
4281            irow.push(k as Index);
4282            jcol.push(j as Index);
4283        }
4284    }
4285    (irow, jcol)
4286}
4287
4288/// Flatten an additive expression tree into independent summand
4289/// expressions, each of which becomes its own Hessian tape.
4290///
4291/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
4292/// each summand tape once *per color the summand touches*, so the
4293/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
4294/// small (few variables → few colors) is what makes a sparse Hessian
4295/// cheap. A single fused tape spanning all `n` variables, by
4296/// contrast, is walked once per color → `O(n · tape_len)`, which on a
4297/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
4298/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
4299///
4300/// We therefore descend through the *affine* envelope of the sum, not
4301/// just `+`/`Sum`:
4302///
4303///   * `Neg(x)`            → split `x`, negate each summand
4304///   * `Sub(l, r)`         → split `l`; split `r`, negate each summand
4305///   * `c * x` / `x * c`   → split `x`, scale each summand by `c`
4306///   * `x / c`             → split `x`, scale each summand by `1/c`
4307///
4308/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
4309/// least-squares / max-entropy shapes) still decomposes to its leaf
4310/// terms instead of collapsing into one giant tape. The carried
4311/// `factor` is materialised onto each leaf only when it differs from
4312/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
4313/// is unchanged and the per-summand op count grows by at most one.
4314fn split_top_sums(expr: &Expr) -> Vec<Expr> {
4315    let mut out = Vec::new();
4316    fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4317        if factor == 1.0 {
4318            out.push(e.clone());
4319        } else if factor == -1.0 {
4320            out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
4321        } else {
4322            out.push(Expr::Binary(
4323                BinOp::Mul,
4324                Box::new(Expr::Const(factor)),
4325                Box::new(e.clone()),
4326            ));
4327        }
4328    }
4329    fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4330        match e {
4331            Expr::Sum(terms) => {
4332                for t in terms {
4333                    go(t, factor, out);
4334                }
4335            }
4336            Expr::Binary(BinOp::Add, l, r) => {
4337                go(l, factor, out);
4338                go(r, factor, out);
4339            }
4340            Expr::Binary(BinOp::Sub, l, r) => {
4341                go(l, factor, out);
4342                go(r, -factor, out);
4343            }
4344            Expr::Unary(UnaryOp::Neg, x) => {
4345                go(x, -factor, out);
4346            }
4347            // Affine scaling: distribute a constant coefficient into
4348            // the summands so a leading `c·(Σ …)` still splits.
4349            Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
4350                (Expr::Const(c), _) => go(r, factor * c, out),
4351                (_, Expr::Const(c)) => go(l, factor * c, out),
4352                _ => push_leaf(e, factor, out),
4353            },
4354            Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
4355                Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
4356                _ => push_leaf(e, factor, out),
4357            },
4358            _ => push_leaf(e, factor, out),
4359        }
4360    }
4361    go(expr, 1.0, &mut out);
4362    if out.is_empty() {
4363        out.push(Expr::Const(0.0));
4364    }
4365    out
4366}
4367
4368/// Greedy column coloring of a symmetric sparsity pattern stored
4369/// as lower-triangle pairs.
4370///
4371/// Builds the column-intersection graph: columns `c1` and `c2` are
4372/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
4373/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
4374/// satisfies the direct-recovery condition for symmetric Hessians
4375/// (Coleman-Moré): for any color, the columns it contains have
4376/// pairwise disjoint row supports, so a single H·s product
4377/// recovers them all unambiguously.
4378///
4379/// Returns `(var_color, n_colors)` where `var_color[k]` is the
4380/// color assigned to variable `k`, or `u32::MAX` for variables
4381/// not in any Hessian pair (they contribute nothing and don't
4382/// need a color).
4383/// A column is treated as **dense** — and peeled out of the coloring —
4384/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
4385/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
4386/// factor keeps uniformly-dense Hessians (where every column looks like
4387/// every other) on the plain coloring path, and the absolute floor stops
4388/// a very sparse average from declaring a 10-entry column "dense".
4389const DENSE_COL_FACTOR: usize = 16;
4390const DENSE_COL_MIN: usize = 32;
4391/// Largest relative error a peeled column may inflict on the smallest
4392/// entry recovered from its pass before
4393/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
4394///
4395/// The ratio is a worst-case bound — the pass's roundoff floor over the
4396/// smallest entry read out of it — and it is a *loose* one, by an amount
4397/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
4398/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
4399/// measures 4e-16. The cut is therefore calibrated on the corpus, not
4400/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
4401/// models that peel anything, the highest bound on a column that recovers
4402/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
4403/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
4404/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
4405///
4406/// 1e-8 sits below both, which deliberately buys correctness with speed:
4407/// the errors are asymmetric, since a false veto costs one model a coloring
4408/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
4409/// while a missed veto costs a solve its certificate. Five of the 56 take a
4410/// veto they do not need; none takes a wrong answer. Raising this constant
4411/// to buy those five back would put the cut inside a 2.8x window measured
4412/// on two model families, which is not a margin worth trading a certificate
4413/// for.
4414const PEEL_MAX_REL_ERR: f64 = 1e-8;
4415/// Hard cap on how many columns get peeled, applied on top of the
4416/// pay-for-itself rule in [`select_peeled_cols`].
4417const MAX_PEELED_COLS: usize = 256;
4418
4419/// Lower bound on the color count that results from peeling `peeled`.
4420///
4421/// Peeling costs one color per peeled column. On what remains, any row
4422/// with `d` surviving entries makes those `d` columns pairwise
4423/// conflicting, so the greedy walk needs at least `d` colors. Hence
4424/// `|peeled| + max surviving row degree` is a lower bound on the total,
4425/// computable in one O(nnz) pass — no coloring required.
4426///
4427/// The bound is what makes the choice decidable at all: the plain
4428/// coloring cannot be run as a comparison baseline, because on the
4429/// one-dense-row case the plain walk is itself O(n^2) — precisely the
4430/// blowup peeling exists to avoid.
4431fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
4432    let mut deg = vec![0usize; n];
4433    for &(i, j) in lower_pairs {
4434        if peeled[i] || peeled[j] {
4435            continue;
4436        }
4437        deg[j] += 1;
4438        if i != j {
4439            deg[i] += 1;
4440        }
4441    }
4442    let n_peeled = peeled.iter().filter(|&&p| p).count();
4443    n_peeled + deg.iter().copied().max().unwrap_or(0)
4444}
4445
4446/// Choose which of the candidate dense columns to actually peel.
4447///
4448/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
4449/// top `k` candidates by degree, over a doubling ladder of `k` up to
4450/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
4451/// so peeling has to earn its colors.
4452///
4453/// **Why not just truncate an over-long candidate list.** Cutting the
4454/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
4455/// columns that miss the cut stay in the conflict structure, so the base
4456/// color count is untouched and the singleton colors are pure addition.
4457/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
4458/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
4459/// regression in exactly the quantity peeling exists to reduce. The
4460/// bound above sees it: peeling `k` of several thousand equal-degree
4461/// columns leaves the surviving max degree unchanged, so every `k > 0`
4462/// scores strictly worse than peeling nothing.
4463///
4464/// **Why not a simple degree rule.** "Peel only columns denser than some
4465/// fraction of `n`" would refuse three rows of degree 10,000 in a
4466/// 200,000-variable model, where peeling three columns takes the
4467/// coloring from >= 10,000 down to a handful. The win depends on what
4468/// peeling leaves behind, not on the peeled column's degree alone.
4469fn select_peeled_cols(
4470    n: usize,
4471    lower_pairs: &[(usize, usize)],
4472    deg: &[usize],
4473    mut candidates: Vec<usize>,
4474) -> Vec<usize> {
4475    if candidates.is_empty() {
4476        return candidates;
4477    }
4478    // Worst offenders first; they remove the most conflict per color spent.
4479    candidates.sort_unstable_by(|&a, &b| deg[b].cmp(&deg[a]).then(a.cmp(&b)));
4480    candidates.truncate(MAX_PEELED_COLS);
4481
4482    let mut mask = vec![false; n];
4483    let mut best_k = 0usize;
4484    // Peeling nothing: the bound is just the largest row degree.
4485    let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
4486
4487    // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
4488    // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
4489    // mask only ever gains entries, so each step just marks the new slice.
4490    let mut marked = 0usize;
4491    let mut k = 1usize;
4492    loop {
4493        let k_now = k.min(candidates.len());
4494        for &j in &candidates[marked..k_now] {
4495            mask[j] = true;
4496        }
4497        marked = k_now;
4498        let bound = peel_color_bound(n, lower_pairs, &mask);
4499        if bound < best_bound {
4500            best_bound = bound;
4501            best_k = k_now;
4502        }
4503        if k_now == candidates.len() {
4504            break;
4505        }
4506        k *= 2;
4507    }
4508
4509    candidates.truncate(best_k);
4510    candidates
4511}
4512
4513/// Greedy distance-1 coloring of the Hessian's column-intersection
4514/// graph, with **dense columns peeled out**.
4515///
4516/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
4517/// marks a column that needs no directional product of its own: either
4518/// it has no Hessian entries at all, or every entry it has is recovered
4519/// from a peeled column's pass (see below).
4520///
4521/// # Why peeling
4522///
4523/// The plain coloring rule is "two columns may share a color when they
4524/// have no common nonzero row". A single **dense row** — one variable
4525/// multiplying a sum over all the others, a total-cost variable, a
4526/// shared design parameter — puts a nonzero in *every* column at that
4527/// row, so every pair of columns conflicts and the greedy walk hands out
4528/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
4529/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
4530/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
4531/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
4532///
4533/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
4534/// own singleton color: one directional product with seed `e_d` recovers
4535/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
4536/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
4537/// at row `j` of that same pass. So row `d` no longer constrains any
4538/// other column's color and is dropped from the conflict structure, and
4539/// the remaining columns colour on their genuine sparsity. On the
4540/// one-dense-row case above this takes `n_colors` from `n` to a handful.
4541///
4542/// # What peeling costs, and `peel_veto`
4543///
4544/// Recovering `H[d, j]` from column `d`'s pass is exact in real
4545/// arithmetic but not in floating point: the pass is accumulated at the
4546/// scale of the whole dense column, so every entry read out of it carries
4547/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
4548/// ordinary path — column `j`'s own pass — would have left a floor of
4549/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
4550/// dense column is well scaled, and they do on every peel-firing model in
4551/// the benchmark corpus but one. Where a peeled column spans a wide
4552/// dynamic range, though, that floor swamps its small entries: a column
4553/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4554///
4555/// Structure cannot see this — it is a property of the values — so
4556/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4557/// and passes the offenders back here in `peel_veto`, which bars them
4558/// from being peeled again. A vetoed column is colored normally, its row
4559/// returns to the conflict structure, and its entries go back to the
4560/// accurate path.
4561fn greedy_hessian_coloring(
4562    n: usize,
4563    lower_pairs: &[(usize, usize)],
4564    peel_veto: &[bool],
4565) -> (Vec<u32>, usize, Vec<bool>) {
4566    if n == 0 {
4567        return (Vec::new(), 0, Vec::new());
4568    }
4569
4570    // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4571    // i >= j contributes row i to column j and row j to column i; a
4572    // diagonal contributes once.
4573    let mut deg = vec![0usize; n];
4574    for &(i, j) in lower_pairs {
4575        deg[j] += 1;
4576        if i != j {
4577            deg[i] += 1;
4578        }
4579    }
4580
4581    // Pick the dense columns to peel.
4582    let total: usize = deg.iter().sum();
4583    let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4584    let mut peeled = vec![false; n];
4585    let candidates: Vec<usize> = (0..n)
4586        .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4587        .collect();
4588    let dense = select_peeled_cols(n, lower_pairs, &deg, candidates);
4589    for &j in &dense {
4590        peeled[j] = true;
4591    }
4592
4593    // Conflict structure over the *non-peeled* columns only. Pairs with
4594    // a peeled endpoint are recovered from that endpoint's own pass, so
4595    // they neither need a color nor constrain one.
4596    let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4597    let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4598    for &(i, j) in lower_pairs {
4599        if peeled[i] || peeled[j] {
4600            continue;
4601        }
4602        col_rows[j].push(i as u32);
4603        row_cols[i].push(j as u32);
4604        if i != j {
4605            col_rows[i].push(j as u32);
4606            row_cols[j].push(i as u32);
4607        }
4608    }
4609
4610    let mut var_color = vec![u32::MAX; n];
4611    let mut forbidden = vec![u32::MAX; n + 1];
4612    let mut n_colors: u32 = 0;
4613
4614    for j in 0..n {
4615        // Peeled columns are colored below; a column with no surviving
4616        // Hessian entries needs no color at all.
4617        if peeled[j] || col_rows[j].is_empty() {
4618            continue;
4619        }
4620        // Mark colors used by any column sharing a row with `j`.
4621        // Row-of-col -> col-in-row visit pattern collects all
4622        // distance-1 neighbors in the column-intersection graph.
4623        for &r in &col_rows[j] {
4624            for &c in &row_cols[r as usize] {
4625                if c as usize == j {
4626                    continue;
4627                }
4628                let cc = var_color[c as usize];
4629                if cc != u32::MAX {
4630                    forbidden[cc as usize] = j as u32;
4631                }
4632            }
4633        }
4634        // First color not stamped with `j as u32`.
4635        let mut chosen: u32 = 0;
4636        while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4637            chosen += 1;
4638        }
4639        var_color[j] = chosen;
4640        if chosen + 1 > n_colors {
4641            n_colors = chosen + 1;
4642        }
4643    }
4644
4645    // One singleton color per peeled column, appended after the shared
4646    // ones so the non-peeled numbering is untouched.
4647    for &j in &dense {
4648        var_color[j] = n_colors;
4649        n_colors += 1;
4650    }
4651
4652    (var_color, n_colors as usize, peeled)
4653}
4654
4655/// Everything downstream of the Hessian coloring: seed vectors, the
4656/// per-color decode table, the per-tape color sets, and the shared-CSE
4657/// per-color summand / prelude-reach tables.
4658///
4659/// Split out of [`NlTnlp::new`] because
4660/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4661/// time, with a peel veto in hand, once it has seen real Hessian values.
4662fn build_color_tables(
4663    n: usize,
4664    m: usize,
4665    lower_pairs: &[(usize, usize)],
4666    tape_mask: &[bool],
4667    peel_veto: &[bool],
4668    obj_tapes: &[Tape],
4669    con_tapes: &[Vec<Tape>],
4670    con_hybrid: Option<&mut ConHybrid>,
4671) -> ColorTables {
4672    // Hessian column coloring. The chromatic number of the
4673    // column-intersection graph bounds how many directional
4674    // Hessian-vector products we need per `eval_h` call —
4675    // typically O(stencil) for PDE-mesh problems.
4676    //
4677    // Only the entries a *tape* can write take part. `tape_mask` is empty
4678    // when every entry is a tape entry (no quadratic structure), which is
4679    // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4680    // entries are excluded, since they are scattered from their stored
4681    // values and never read out of a directional product. A variable that
4682    // appears only in quadratic rows then has no color at all
4683    // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4684    // column with no surviving entries.
4685    let colored_pairs: Vec<(usize, usize)>;
4686    let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4687        lower_pairs
4688    } else {
4689        colored_pairs = lower_pairs
4690            .iter()
4691            .zip(tape_mask)
4692            .filter_map(|(p, &t)| t.then_some(*p))
4693            .collect();
4694        &colored_pairs
4695    };
4696    let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4697
4698    // Per-color seed vectors (dense for O(1) Var lookup in
4699    // `Tape::hessian_directional`).
4700    let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4701    for (k, &c) in var_color.iter().enumerate() {
4702        if c != u32::MAX {
4703            seeds[c as usize][k] = 1.0;
4704        }
4705    }
4706
4707    // Per-color decoding table. For each lower-tri pair (i, j)
4708    // with i >= j, the entry belongs to column j's color: after
4709    // computing compressed_{c_j} = (H · s_{c_j}), the value at
4710    // row i is exactly H[i, j] (coloring guarantees no other
4711    // column in c_j has a nonzero at row i).
4712    // Built straight from `lower_pairs`, which is sorted, so each
4713    // color's table is in ascending `hess_idx` order and the decode
4714    // scatter walks `values` forward instead of hopping (the old
4715    // build drained a `HashMap`, whose iteration order is arbitrary).
4716    let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4717    for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4718        // An entry no tape writes has no pass to be decoded out of; the
4719        // quadratic scatter already put its value there.
4720        if !tape_mask.is_empty() && !tape_mask[idx] {
4721            continue;
4722        }
4723        // Which directional product recovers H[i, j]? Column `j`'s,
4724        // read at row `i` — except when `i` is a peeled column and
4725        // `j` is not: then `j` may have no color of its own, and the
4726        // entry is already in column `i`'s pass at row `j`, since
4727        // H[i, j] == H[j, i].
4728        let (c, row) = if peeled[i] && !peeled[j] {
4729            (var_color[i], j)
4730        } else {
4731            (var_color[j], i)
4732        };
4733        debug_assert!(
4734            c != u32::MAX,
4735            "Hessian pair ({i}, {j}) at index {idx} has no color"
4736        );
4737        decoding[c as usize].push(ColorWrite {
4738            row: row as u32,
4739            hess_idx: idx as u32,
4740        });
4741    }
4742
4743    // Per-tape distinct color set: for each tape, the colors
4744    // its variables fall into. `eval_h` loops over only these
4745    // (tape, color) pairs instead of n_tapes × n_colors.
4746    let tape_colors = |t: &Tape| -> Vec<u32> {
4747        let mut s: Vec<u32> = t
4748            .variables()
4749            .into_iter()
4750            .map(|v| var_color[v])
4751            .filter(|&c| c != u32::MAX)
4752            .collect();
4753        s.sort_unstable();
4754        s.dedup();
4755        s
4756    };
4757    let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4758    let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4759        .iter()
4760        .map(|row| row.iter().map(tape_colors).collect())
4761        .collect();
4762
4763    // Shared-CSE Hessian tables (issue #557): the per-color summand
4764    // lists, row lookup, and packed forward-value arena `eval_h`'s
4765    // hybrid path walks. Built whenever the hybrid tape is — not just
4766    // above the gate — so flipping `use_for_hess` on (tests, the
4767    // force env var) needs no extra setup; the cost is one f64 per
4768    // local op plus small index tables.
4769    if let Some(h) = con_hybrid {
4770        let n_sum = h.tape.n_summands();
4771        let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4772        let mut acc = 0usize;
4773        for s in &h.tape.summands {
4774            local_off.push(acc);
4775            acc += s.ops.len();
4776        }
4777        local_off.push(acc);
4778        h.local_vals_all = vec![0.0; acc];
4779        h.local_off = local_off;
4780
4781        let mut summand_row = vec![0u32; n_sum];
4782        for i in 0..m {
4783            for si in h.row_start[i]..h.row_start[i + 1] {
4784                summand_row[si] = i as u32;
4785            }
4786        }
4787        h.summand_row = summand_row;
4788
4789        // A summand's variable set (`all_vars`) equals its flat tape's,
4790        // so this is `con_tape_colors` inverted to color-major order —
4791        // the loop `eval_h` actually runs.
4792        let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4793        for (si, s) in h.tape.summands.iter().enumerate() {
4794            let mut cs: Vec<u32> = s
4795                .all_vars
4796                .iter()
4797                .map(|&v| var_color[v])
4798                .filter(|&c| c != u32::MAX)
4799                .collect();
4800            cs.sort_unstable();
4801            cs.dedup();
4802            for c in cs {
4803                by_color[c as usize].push(si as u32);
4804            }
4805        }
4806        // Per-color prelude reach: the union of `prelude_reach` over the
4807        // color's summands, ascending. A union of operand-closed
4808        // ascending sets is itself operand-closed and ascending, which is
4809        // exactly what the two prelude sweeps require. Deduped with an
4810        // epoch-tagged buffer so the build costs
4811        // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4812        // it saves — rather than `n_colors × |prelude|`.
4813        let np = h.tape.n_prelude_ops();
4814        let mut seen: Vec<u32> = vec![0; np];
4815        let mut epoch: u32 = 0;
4816        let mut reach: Vec<u32> = Vec::new();
4817        let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4818        for list in &by_color {
4819            reach_off.push(reach.len());
4820            epoch += 1;
4821            let start = reach.len();
4822            for &si in list {
4823                for &p in &h.tape.summands[si as usize].prelude_reach {
4824                    if seen[p] != epoch {
4825                        seen[p] = epoch;
4826                        reach.push(p as u32);
4827                    }
4828                }
4829            }
4830            reach[start..].sort_unstable();
4831        }
4832        reach_off.push(reach.len());
4833        h.hess_color_reach = reach;
4834        h.hess_color_reach_off = reach_off;
4835
4836        h.hess_color_summands = by_color;
4837        h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4838        h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4839        h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4840        h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4841        h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4842    }
4843
4844    ColorTables {
4845        var_color,
4846        n_colors,
4847        peeled_cols: peeled
4848            .iter()
4849            .enumerate()
4850            .filter(|(_, p)| **p)
4851            .map(|(j, _)| j as u32)
4852            .collect(),
4853        seeds,
4854        decoding,
4855        hess_color_index: HessColorIndex::build(n_colors, &con_tape_colors),
4856        obj_tape_colors,
4857        con_tape_colors,
4858    }
4859}
4860
4861/// The color-dependent half of an [`NlTnlp`], as built by
4862/// [`build_color_tables`].
4863struct ColorTables {
4864    var_color: Vec<u32>,
4865    n_colors: usize,
4866    /// Columns given a singleton color and dropped from the conflict
4867    /// structure. Small by construction (`MAX_PEELED_COLS`).
4868    peeled_cols: Vec<u32>,
4869    seeds: Vec<Vec<f64>>,
4870    decoding: Vec<Vec<ColorWrite>>,
4871    obj_tape_colors: Vec<Vec<u32>>,
4872    con_tape_colors: Vec<Vec<Vec<u32>>>,
4873    /// `con_tape_colors` inverted to color-major, CSR-packed: the (row, tape)
4874    /// pairs whose tape touches each color, and the objective tapes likewise.
4875    ///
4876    /// The flat `eval_h` walks tapes and, inside each, the colors it touches,
4877    /// so several rows accumulate into one `compressed[c]` — fine serially,
4878    /// unshareable across threads. Walking color-major makes each color's
4879    /// accumulator private to its task, at the cost of forwarding a tape once
4880    /// per color it touches instead of once (measured at 2.9 colors per tape
4881    /// on an AC-SCOPF model, against a directional sweep that costs several
4882    /// forwards, so ~20% more serial work for 28-way parallelism).
4883    ///
4884    /// Within a color the pairs stay in ascending (row, tape) order, which is
4885    /// the order the tape-major loop contributes them in, so the two walks
4886    /// accumulate identical sums in identical order — bit-for-bit, not merely
4887    /// within tolerance. `parallel_matches_serial_bit_for_bit` pins that.
4888    hess_color_index: HessColorIndex,
4889}
4890
4891/// Color-major (row, tape) work lists for the flat Hessian path. See
4892/// [`ColorTables::hess_color_index`].
4893#[derive(Debug, Default, Clone)]
4894pub(crate) struct HessColorIndex {
4895    /// Constraint (row, tape) pairs per color, CSR. The objective's tapes are
4896    /// deliberately absent: `eval_h` accumulates them into `compressed`
4897    /// *before* the constraint block and keeps doing so serially, which is
4898    /// what makes the parallel walk's per-color ordering identical to the
4899    /// serial one — objective first, then rows ascending.
4900    con_off: Vec<u32>,
4901    con_row: Vec<u32>,
4902    con_tape: Vec<u32>,
4903}
4904
4905impl HessColorIndex {
4906    fn build(n_colors: usize, con_tape_colors: &[Vec<Vec<u32>>]) -> Self {
4907        // Rows ascending, then tapes ascending: the tape-major loop's order.
4908        let mut con: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_colors];
4909        for (k, row) in con_tape_colors.iter().enumerate() {
4910            for (ti, colors) in row.iter().enumerate() {
4911                for &c in colors {
4912                    con[c as usize].push((k as u32, ti as u32));
4913                }
4914            }
4915        }
4916        let mut out = HessColorIndex {
4917            con_off: Vec::with_capacity(n_colors + 1),
4918            ..Default::default()
4919        };
4920        out.con_off.push(0);
4921        for list in &con {
4922            for &(k, ti) in list {
4923                out.con_row.push(k);
4924                out.con_tape.push(ti);
4925            }
4926            out.con_off.push(out.con_row.len() as u32);
4927        }
4928        out
4929    }
4930
4931    /// The (row, tape) pairs of color `c`, as two parallel slices.
4932    fn con_pairs(&self, c: usize) -> (&[u32], &[u32]) {
4933        let (a, b) = (self.con_off[c] as usize, self.con_off[c + 1] as usize);
4934        (&self.con_row[a..b], &self.con_tape[a..b])
4935    }
4936}
4937
4938impl NlTnlp {
4939    /// Build the TNLP, panicking if AMPL external-function resolution fails.
4940    ///
4941    /// Kept for the many infallible call sites (tests) that operate on `.nl`
4942    /// models known to need no external libraries. Surfaces that can be
4943    /// handed an arbitrary user model — the CLI solve and `verify` paths, the
4944    /// Python `read_nl` binding — must call [`Self::try_new`] instead so a
4945    /// missing `$AMPLFUNC` library becomes an ordinary error rather than a
4946    /// panic (gh#1069: the CLI exited 101 with a backtrace hint).
4947    pub fn new(prob: NlProblem) -> Self {
4948        Self::try_new(prob)
4949            .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4950    }
4951
4952    /// Build the TNLP, returning an error (instead of panicking) when AMPL
4953    /// imported functions named by the model can't be resolved — e.g.
4954    /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4955    /// referenced function id isn't registered by any loaded library.
4956    pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4957        // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4958        // objective — the A/B reference for the constant-structure path,
4959        // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4960        // fast path's derivatives are checked against the ones they replace
4961        // on a real model, and how a suspected fast-path bug is bisected
4962        // against a reference that computes the same numbers a different way.
4963        Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4964    }
4965
4966    /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4967    /// explicitly on or off.
4968    ///
4969    /// The env var `try_new` reads is process-global, which is exactly wrong
4970    /// for the differential test that has to build the *same* model both ways
4971    /// and compare the derivatives — so the knob is a parameter here and the
4972    /// env var only chooses its default.
4973    pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4974        // Resolve any AMPL imported (external) functions. Walk every
4975        // nonlinear expression to collect the funcall ids actually
4976        // referenced; load the libraries named in $AMPLFUNC and bind
4977        // each id to its (library, registered-name) pair so the tape
4978        // builder can emit live `TapeOp::Funcall` ops.
4979        let mut referenced: BTreeSet<usize> = BTreeSet::new();
4980        // Only trees can carry a `Funcall`: the recognizer refuses one, so
4981        // a recognized body provably contains none.
4982        for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4983            if let Some(e) = body.tree() {
4984                super::nl_external::collect_funcall_ids(e, &mut referenced);
4985            }
4986        }
4987        let resolver = if referenced.is_empty() {
4988            super::nl_external::ExternalResolver::default()
4989        } else {
4990            super::nl_external::ExternalResolver::build_for_problem(
4991                &prob.imported_funcs,
4992                &referenced,
4993            )?
4994        };
4995
4996        // Recognize the degree-≤2 objective and rows *before* anything is
4997        // taped, because the win is not in evaluating the tape faster — it
4998        // is in never building it. On `qcqp500-3c` the ten quadratic rows
4999        // are 2.32 M monomials, one `Tape` each; recognizing them first
5000        // means those 2.32 M tapes, their color lists and their per-tape
5001        // sparsity sets are never allocated.
5002        //
5003        // A row that is trivially zero is left alone: it has no nonlinear
5004        // part to replace, and routing it through a (necessarily empty)
5005        // quadratic form would touch every model in the corpus to save
5006        // nothing.
5007        let mut quad = QuadraticStructure::new(prob.m);
5008        if use_quadratic {
5009            // `is_expanded_quadratic` is the accuracy gate, not an
5010            // optimization: it admits only forms whose read-out repeats the
5011            // additions the `.nl` writer already wrote. See its docs — and
5012            // note it is checked *before* recognition, so a factored form
5013            // costs one cheap structural walk rather than a full expansion.
5014            // Recognition is the parser's job now (gh #588, Q5) for the
5015            // bodies it could reach; `admitted_quad_form` reads its answer
5016            // back, and still walks a tree for the bodies that kept one —
5017            // a `from_expressions` model, a factored row rewound by the
5018            // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
5019            //
5020            // A form that gate refuses is offered the *factored* read-out
5021            // before it falls back to a tape (gh #673): the gate is a
5022            // verdict on the expansion, not on the body, and a sum of
5023            // squared residuals keeps its structure by keeping its squares.
5024            // `push_body_form` owns that order.
5025            if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
5026                quad.assign_objective(f);
5027            }
5028            for k in 0..prob.m {
5029                if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
5030                    quad.assign_row(k, f);
5031                }
5032            }
5033        }
5034
5035        // Flatten objective and each constraint into independent
5036        // summands. Each summand becomes its own `Tape` (CSE bodies
5037        // are deduplicated within a tape via Rc identity in
5038        // `Tape::build`; bodies shared across summands are
5039        // duplicated, which we accept as a simplicity tradeoff).
5040        let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
5041            Vec::new()
5042        } else {
5043            split_top_sums(&prob.obj_expr())
5044                .iter()
5045                .map(|e| Tape::build_with_externals(e, &resolver))
5046                .collect()
5047        };
5048
5049        let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
5050        let mut con_roots: Vec<Expr> = Vec::new();
5051        let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
5052        for k in 0..prob.m {
5053            row_start.push(con_roots.len());
5054            // A row with a quadratic form contributes no tape and no
5055            // hybrid-tape root, so `con_tapes[k]` is empty and its
5056            // `row_start` range is empty too.
5057            if quad.row_form(k).is_some() {
5058                con_tapes.push(Vec::new());
5059                continue;
5060            }
5061            let summands = split_top_sums(&prob.con_expr(k));
5062            con_tapes.push(
5063                summands
5064                    .iter()
5065                    .map(|e| Tape::build_with_externals(e, &resolver))
5066                    .collect(),
5067            );
5068            // Move (not clone) the split summands into the root list: their
5069            // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
5070            // sharing on `Arc` pointer identity, so the roots must be the
5071            // same allocations the parse produced.
5072            con_roots.extend(summands);
5073        }
5074        row_start.push(con_roots.len());
5075
5076        // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
5077        // building only when some CSE body is actually referenced from two
5078        // or more summands — otherwise the prelude comes out empty and the
5079        // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
5080        // gates the opcodes `build_multi` would panic on.
5081        // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
5082        // whole constraint block. Diagnostic only: it is how the
5083        // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
5084        // on a real model, and how a suspected hybrid-path bug is bisected
5085        // against a reference that computes the same derivatives a
5086        // different way.
5087        let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
5088            None
5089        } else if hybrid_supported(&con_roots) {
5090            let tape = HybridTape::build_multi(&con_roots);
5091            (tape.n_prelude_ops() > 0).then(|| {
5092                let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5093                let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
5094                // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
5095                // regardless of the op ratio. Diagnostic only — it is how the
5096                // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
5097                // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
5098                // models that sit below the gate.
5099                let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
5100                ConHybrid {
5101                    prelude_vals: vec![0.0; tape.n_prelude_ops()],
5102                    local_vals: vec![0.0; tape.max_summand_ops()],
5103                    local_adj: vec![0.0; tape.max_summand_ops()],
5104                    prelude_adj: vec![0.0; tape.n_prelude_ops()],
5105                    use_for_jac: flat_ops as f64
5106                        >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
5107                    use_for_hess: force_hess
5108                        || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
5109                    local_vals_all: Vec::new(),
5110                    local_off: Vec::new(),
5111                    summand_row: Vec::new(),
5112                    hess_color_summands: Vec::new(),
5113                    hess_color_reach: Vec::new(),
5114                    hess_color_reach_off: Vec::new(),
5115                    prelude_dot: Vec::new(),
5116                    hess_prelude_adj: Vec::new(),
5117                    prelude_adj_dot: Vec::new(),
5118                    local_dot: Vec::new(),
5119                    local_adj_dot: Vec::new(),
5120                    row_start,
5121                    tape,
5122                }
5123            })
5124        } else {
5125            None
5126        };
5127        drop(con_roots);
5128
5129        // Hessian-of-Lagrangian sparsity: union of each tape's own
5130        // structural Hessian sparsity.
5131        // One flat `Vec`, sorted and deduped once, rather than a global
5132        // `BTreeSet` fed a single insert at a time across every summand
5133        // in the model: sort+dedup walks contiguous memory where the tree
5134        // chased a pointer and allocated a node per entry. The result is
5135        // exactly the ascending order the rest of this function wants, so
5136        // it doubles as `lower_pairs` instead of being copied into it.
5137        let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
5138        for t in &obj_tapes {
5139            tape_pairs.extend(t.hessian_sparsity());
5140        }
5141        for row in &con_tapes {
5142            for t in row {
5143                tape_pairs.extend(t.hessian_sparsity());
5144            }
5145        }
5146        tape_pairs.sort_unstable();
5147        tape_pairs.dedup();
5148
5149        // The assembled pattern is the tapes' union *plus* the quadratic
5150        // forms'. They are kept apart because the coloring only ever needs
5151        // the tape half — a quadratic block's entries are scattered
5152        // directly, not recovered from a directional product — and coloring
5153        // a dense quadratic block would put the color count back at `n` to
5154        // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
5155        let mut lower_pairs = tape_pairs.clone();
5156        if !quad.is_empty() {
5157            for f in quad
5158                .objective_form()
5159                .into_iter()
5160                .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
5161            {
5162                lower_pairs.extend(
5163                    quad.lower_triangle(f)
5164                        .map(|(r, c, _)| (r as usize, c as usize)),
5165                );
5166            }
5167            lower_pairs.sort_unstable();
5168            lower_pairs.dedup();
5169        }
5170
5171        // Which assembled entries a tape can write. Both sides are sorted
5172        // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
5173        // mask is left empty (meaning "all of them") when nothing was
5174        // recognized.
5175        let h_tape_mask: Vec<bool> = if quad.is_empty() {
5176            Vec::new()
5177        } else {
5178            let mut mask = vec![false; lower_pairs.len()];
5179            let mut t = 0usize;
5180            for (idx, pair) in lower_pairs.iter().enumerate() {
5181                if t < tape_pairs.len() && tape_pairs[t] == *pair {
5182                    mask[idx] = true;
5183                    t += 1;
5184                }
5185            }
5186            debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
5187            mask
5188        };
5189        drop(tape_pairs);
5190
5191        let mut h_irow = Vec::with_capacity(lower_pairs.len());
5192        let mut h_jcol = Vec::with_capacity(lower_pairs.len());
5193        for &(hi, lo) in &lower_pairs {
5194            h_irow.push(hi as i32);
5195            h_jcol.push(lo as i32);
5196        }
5197
5198        // Bind each form's lower-triangle entries to their index in the
5199        // assembled pattern. `lower_pairs` is sorted, so the lookup is a
5200        // binary search — done once here, never again on the hot path.
5201        if !quad.is_empty() {
5202            quad.bind_slots(|r, c| {
5203                lower_pairs
5204                    .binary_search(&(r as usize, c as usize))
5205                    .unwrap_or_else(|_| {
5206                        unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
5207                    })
5208            });
5209        }
5210
5211        // Hessian column coloring and everything keyed off it. The
5212        // chromatic number of the column-intersection graph bounds how
5213        // many directional Hessian-vector products we need per `eval_h`
5214        // call — typically O(stencil) for PDE-mesh problems.
5215        let ColorTables {
5216            var_color,
5217            n_colors,
5218            peeled_cols,
5219            seeds,
5220            decoding,
5221            obj_tape_colors,
5222            con_tape_colors,
5223            hess_color_index,
5224        } = build_color_tables(
5225            prob.n,
5226            prob.m,
5227            &lower_pairs,
5228            &h_tape_mask,
5229            &vec![false; prob.n],
5230            &obj_tapes,
5231            &con_tapes,
5232            con_hybrid.as_mut(),
5233        );
5234
5235        // Per-row Jacobian sparsity = union of tape vars plus
5236        // linear-segment vars.
5237        let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
5238        let mut jac_nnz = 0;
5239        for (i, row_tapes) in con_tapes.iter().enumerate() {
5240            let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
5241            for t in row_tapes {
5242                cols.extend(t.variables());
5243            }
5244            // A quadratic row has no tape, so its Jacobian support comes
5245            // from the form: `Hx + a` is nonzero exactly on the union of the
5246            // Hessian's rows and the folded linear part.
5247            if let Some(f) = quad.row_form(i) {
5248                cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
5249            }
5250            cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
5251            cols.sort_unstable();
5252            cols.dedup();
5253            cols.shrink_to_fit();
5254            jac_nnz += cols.len();
5255            jac_cols.push(cols);
5256        }
5257
5258        let mut max_tape_n: usize = 0;
5259        for t in &obj_tapes {
5260            max_tape_n = max_tape_n.max(t.ops.len());
5261        }
5262        for row in &con_tapes {
5263            for t in row {
5264                max_tape_n = max_tape_n.max(t.ops.len());
5265            }
5266        }
5267
5268        if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
5269            // Mean number of Hessian colors a single constraint tape touches.
5270            // It is the cost multiplier of visiting the work color-major
5271            // rather than tape-major: each (tape, color) pair needs the
5272            // tape's forward values, so a color-major walk forwards each tape
5273            // this many times instead of once.
5274            let (mut tape_color_pairs, mut n_live_tapes) = (0usize, 0usize);
5275            for row in &con_tape_colors {
5276                for cols in row {
5277                    if !cols.is_empty() {
5278                        tape_color_pairs += cols.len();
5279                        n_live_tapes += 1;
5280                    }
5281                }
5282            }
5283            let colors_per_tape = tape_color_pairs as f64 / n_live_tapes.max(1) as f64;
5284            let n_obj = obj_tapes.len();
5285            let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
5286            let total = n_obj + n_con;
5287            let mut sum_ops: usize = 0;
5288            for t in &obj_tapes {
5289                sum_ops += t.ops.len();
5290            }
5291            for row in &con_tapes {
5292                for t in row {
5293                    sum_ops += t.ops.len();
5294                }
5295            }
5296            let t = total.max(1);
5297            let nnz_h = h_irow.len();
5298            let avg_decode =
5299                decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
5300            eprintln!(
5301                "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
5302                 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
5303                 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h} \
5304                 colors_per_tape={colors_per_tape:.2}",
5305                sum_ops as f64 / t as f64,
5306            );
5307            // Flat vs shared-CSE op counts for the constraint block. The
5308            // ratio is how much duplicated CSE work `eval_g`'s hybrid path
5309            // avoids, and the ceiling on what routing the Jacobian /
5310            // Hessian through the same prelude could save.
5311            match &con_hybrid {
5312                Some(h) => {
5313                    let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5314                    let prelude = h.tape.n_prelude_ops();
5315                    let local = h.tape.total_local_ops();
5316                    eprintln!(
5317                        "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
5318                         local_ops={local} shared_total={} flat/shared={:.2}x \
5319                         jac_gate={} hess_gate={}",
5320                        prelude + local,
5321                        flat as f64 / (prelude + local).max(1) as f64,
5322                        if h.use_for_jac { "on" } else { "off" },
5323                        if h.use_for_hess { "on" } else { "off" },
5324                    );
5325                }
5326                None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
5327            }
5328            // What the constant-structure path took off the tape builder.
5329            // `forms` counts the objective too, which is why it can exceed
5330            // the row count by one.
5331            let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
5332            // How many of those forms the *parser* produced, i.e. how many
5333            // never cost an `Expr` (gh #588, Q5). The rest were recognized
5334            // from a tree that was built: a `from_expressions` model, or a
5335            // body the parser rewound because it was not degree 2.
5336            let parsed = std::iter::once(&prob.obj_nonlinear)
5337                .chain(prob.con_nonlinear.iter())
5338                .filter(|b| b.quad().is_some())
5339                .count();
5340            eprintln!(
5341                "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
5342                 stored_h_entries={} colored_pairs={}/{}",
5343                quad.len(),
5344                prob.m,
5345                if quad.objective_form().is_some() {
5346                    "quadratic"
5347                } else {
5348                    "taped"
5349                },
5350                quad.stored_entries(),
5351                if h_tape_mask.is_empty() {
5352                    lower_pairs.len()
5353                } else {
5354                    h_tape_mask.iter().filter(|&&t| t).count()
5355                },
5356                lower_pairs.len(),
5357            );
5358        }
5359
5360        let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
5361
5362        let mut me = Self {
5363            prob,
5364            obj_tapes,
5365            con_tapes,
5366            con_hybrid,
5367            quad,
5368            h_tape_mask,
5369            h_irow,
5370            h_jcol,
5371            jac_cols,
5372            jac_nnz,
5373            seeds,
5374            decoding,
5375            obj_tape_colors,
5376            con_tape_colors,
5377            hess_color_index,
5378            jac_row_off: Vec::new(),
5379            #[cfg(not(target_arch = "wasm32"))]
5380            jac_scratch_pool: Vec::new(),
5381            var_color,
5382            peeled_cols,
5383            final_x: None,
5384            final_obj: 0.0,
5385            final_lambda: None,
5386            final_z_l: None,
5387            final_z_u: None,
5388            scratch_row_grad: Vec::new(),
5389            vals_scratch: vec![0.0; max_tape_n],
5390            dot_scratch: vec![0.0; max_tape_n],
5391            adj_scratch: vec![0.0; max_tape_n],
5392            adj_dot_scratch: vec![0.0; max_tape_n],
5393            compressed,
5394            hvp_live: Vec::new(),
5395            curvature_scaling: None,
5396        };
5397        me.veto_ill_conditioned_peels();
5398        Ok(me)
5399    }
5400
5401    /// Un-peel any dense column whose own pass is too ill-scaled to read
5402    /// its small entries out of, and re-color if that changes anything.
5403    ///
5404    /// `greedy_hessian_coloring` picks the peel set from structure alone,
5405    /// which is the right call for the memory and the color count but
5406    /// blind to the one thing that can go wrong: an entry recovered from
5407    /// column `d`'s pass inherits that pass's roundoff floor, about
5408    /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
5409    /// well-scaled dense column loses nothing to that — the recovered
5410    /// entries come back bit-identical to the uncompressed reference on
5411    /// every peel-firing model in the benchmark corpus but one. A column
5412    /// spanning many orders of magnitude, though, hands its small entries
5413    /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
5414    /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
5415    /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
5416    /// survives that, but the multipliers come out of the KKT system the
5417    /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
5418    /// the solve stalls short of `Optimal` on a problem it used to
5419    /// certify.
5420    ///
5421    /// Nothing structural distinguishes the two cases, so measure it: one
5422    /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
5423    /// column's exact pass sitting in `compressed`, and a column whose
5424    /// worst recovered entry would lose more than
5425    /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
5426    /// one `eval_h` — at the peeled color count, so cheap — and only for
5427    /// the ~3% of models that peel anything at all.
5428    fn veto_ill_conditioned_peels(&mut self) {
5429        if self.peeled_cols.is_empty() {
5430            return;
5431        }
5432
5433        let mut values = vec![0.0; self.h_irow.len()];
5434        let lambda = vec![1.0; self.prob.m];
5435        let x0 = self.prob.x0.clone();
5436        if !self.eval_h(
5437            Some(&x0),
5438            true,
5439            1.0,
5440            Some(&lambda),
5441            true,
5442            SparsityRequest::Values {
5443                values: &mut values,
5444            },
5445        ) {
5446            return;
5447        }
5448
5449        let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
5450        // A column whose whole pass is negligible against the Hessian as a
5451        // whole cannot move the KKT system no matter how badly its own
5452        // entries are rounded, and columns that are identically zero at
5453        // `x0` would otherwise veto on a ratio of pure noise.
5454        let h_scale = self
5455            .compressed
5456            .iter()
5457            .flat_map(|c| c.iter())
5458            .fold(0.0f64, |a, &v| a.max(v.abs()));
5459        let mut peel_veto = vec![false; self.prob.n];
5460        let mut vetoed = 0usize;
5461        for &d in &self.peeled_cols {
5462            let c = self.var_color[d as usize];
5463            if c == u32::MAX {
5464                continue;
5465            }
5466            let pass = &self.compressed[c as usize];
5467            // The floor the pass was accumulated at, against the smallest
5468            // entry actually read out of it.
5469            let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
5470            // Entries at or below the pass's own roundoff floor carry no
5471            // information to lose: `eps * scale` is the noise the pass was
5472            // accumulated at, so such an entry is already indistinguishable
5473            // from zero whether or not the column is peeled. Including them
5474            // would divide by that noise -- `orthregd` holds entries of
5475            // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
5476            // 4e-16 against an uncompressed reference.
5477            let noise = f64::EPSILON * scale;
5478            let smallest = self.decoding[c as usize]
5479                .iter()
5480                .map(|w| pass[w.row as usize].abs())
5481                .filter(|v| *v > noise)
5482                .fold(f64::INFINITY, f64::min);
5483            if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
5484                continue;
5485            }
5486            if scale <= h_scale * f64::EPSILON {
5487                continue;
5488            }
5489            let rel_err = f64::EPSILON * scale / smallest;
5490            if dbg {
5491                eprintln!(
5492                    "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
5493                     min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
5494                    if rel_err > PEEL_MAX_REL_ERR {
5495                        " VETO"
5496                    } else {
5497                        ""
5498                    }
5499                );
5500            }
5501            if rel_err > PEEL_MAX_REL_ERR {
5502                peel_veto[d as usize] = true;
5503                vetoed += 1;
5504            }
5505        }
5506
5507        if vetoed == 0 {
5508            return;
5509        }
5510        if dbg {
5511            eprintln!(
5512                "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
5513                self.peeled_cols.len()
5514            );
5515        }
5516        self.recolor(&peel_veto);
5517    }
5518
5519    /// Rebuild the coloring and everything keyed off it, barring
5520    /// `peel_veto` from the peel set.
5521    fn recolor(&mut self, peel_veto: &[bool]) {
5522        let lower_pairs: Vec<(usize, usize)> = self
5523            .h_irow
5524            .iter()
5525            .zip(&self.h_jcol)
5526            .map(|(&i, &j)| (i as usize, j as usize))
5527            .collect();
5528        let ColorTables {
5529            var_color,
5530            n_colors,
5531            peeled_cols,
5532            seeds,
5533            decoding,
5534            obj_tape_colors,
5535            con_tape_colors,
5536            hess_color_index,
5537        } = build_color_tables(
5538            self.prob.n,
5539            self.prob.m,
5540            &lower_pairs,
5541            &self.h_tape_mask,
5542            peel_veto,
5543            &self.obj_tapes,
5544            &self.con_tapes,
5545            self.con_hybrid.as_mut(),
5546        );
5547        self.var_color = var_color;
5548        self.peeled_cols = peeled_cols;
5549        self.seeds = seeds;
5550        self.decoding = decoding;
5551        self.obj_tape_colors = obj_tape_colors;
5552        self.con_tape_colors = con_tape_colors;
5553        // Recoloring invalidates the color-major index: its entries name
5554        // colors, so a stale one would send a row's contribution to whatever
5555        // color now holds that slot. Refreshed here, not rebuilt lazily,
5556        // because nothing downstream can detect the staleness.
5557        self.hess_color_index = hess_color_index;
5558        self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
5559    }
5560
5561    pub fn final_x(&self) -> Option<&[Number]> {
5562        self.final_x.as_deref()
5563    }
5564
5565    pub fn final_obj(&self) -> Number {
5566        self.final_obj
5567    }
5568
5569    /// Converged constraint multipliers from the last solve, in original
5570    /// `.nl` row order. `None` before a solve finishes. See
5571    /// [`Self::final_x`] for the primal counterpart.
5572    pub fn final_lambda(&self) -> Option<&[Number]> {
5573        self.final_lambda.as_deref()
5574    }
5575
5576    /// Converged lower / upper bound multipliers from the last solve, in
5577    /// original `.nl` variable order and Ipopt's internal convention (both
5578    /// `>= 0`). `None` before a solve finishes.
5579    pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
5580        Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
5581    }
5582
5583    /// The parsed problem this TNLP evaluates (bounds, starting point,
5584    /// names, suffixes). Read-only; per-instance overrides go through
5585    /// [`Self::variant`].
5586    pub fn problem(&self) -> &NlProblem {
5587        &self.prob
5588    }
5589
5590    /// Opt this model in to **curvature-based** scaling (gh #703): compute
5591    /// the per-variable and per-row factors of
5592    /// [`crate::nl_scaling::curvature_scaling`] and serve them from
5593    /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
5594    /// them through the channel it already has for user factors.
5595    ///
5596    /// Returns `false` when the model is not one the scheme is defined for
5597    /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
5598    /// exists. The caller must surface that as an error rather than solving
5599    /// unscaled: an accepted scaling option that is then quietly not applied
5600    /// is exactly the gh #483 failure.
5601    ///
5602    /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
5603    /// passes over the magnitude surrogates, and nothing at all for a solve
5604    /// that never calls it.
5605    pub fn enable_curvature_scaling(&mut self) -> bool {
5606        match crate::nl_scaling::curvature_scaling(&self.prob) {
5607            Some(sc) => {
5608                self.curvature_scaling = Some(sc);
5609                true
5610            }
5611            None => false,
5612        }
5613    }
5614
5615    /// Whether [`Self::enable_curvature_scaling`] has been called and
5616    /// succeeded.
5617    pub fn curvature_scaling_enabled(&self) -> bool {
5618        self.curvature_scaling.is_some()
5619    }
5620
5621    /// Whether the enabled curvature scaling actually read any curvature —
5622    /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
5623    /// scaling is not enabled, and `false` for a degree-≤2 model whose every
5624    /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
5625    /// equilibration of `[A b]`.
5626    pub fn curvature_scaling_read_curvature(&self) -> bool {
5627        self.curvature_scaling
5628            .as_ref()
5629            .is_some_and(|sc| sc.quadratic)
5630    }
5631
5632    /// Is constraint row `i` evaluated from a constant quadratic form
5633    /// rather than from an AD tape (gh #588, Q4)?
5634    ///
5635    /// Structural, so it answers before any evaluation. Exposed for the
5636    /// differential test, which has to know which models exercise the fast
5637    /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
5638    pub fn quadratic_row(&self, i: usize) -> bool {
5639        self.quad.row_form(i).is_some()
5640    }
5641
5642    /// As [`Self::quadratic_row`], for the objective.
5643    pub fn quadratic_objective(&self) -> bool {
5644        self.quad.objective_form().is_some()
5645    }
5646
5647    /// Structural set of variables that appear in *some* nonlinear part —
5648    /// the union of `collect_vars` over the objective and every constraint
5649    /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5650    /// list below so the two can never disagree.
5651    fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5652        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5653        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5654        for row in &self.prob.con_nonlinear {
5655            row.collect_vars(&mut nonlinear);
5656        }
5657        nonlinear
5658    }
5659
5660    /// The nonlinear-variable list published through the TNLP contract
5661    /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5662    /// ascending, in the C index style [`NlTnlp`] reports.
5663    ///
5664    /// The contract's asymmetry decides how this is computed. A consumer
5665    /// treats every variable *absent* from the list as linear — Ipopt's
5666    /// limited-memory Hessian skips the quasi-Newton update in that
5667    /// subspace — so naming too few variables is a wrong answer, while
5668    /// naming too many merely costs work. Hence:
5669    ///
5670    /// * When the `.nl` header says **every** variable is nonlinear, publish
5671    ///   that and skip the walk. This is the maximally conservative answer,
5672    ///   so it is sound whatever the header's provenance, and it is the
5673    ///   common case for the models this matters on (`eigena2`: 110 of 110).
5674    /// * Otherwise walk the trees. The header's prefix
5675    ///   (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5676    ///   answer, but it would be one that *trusts* the writer to have
5677    ///   ordered the variables as the format requires, and the walk is
5678    ///   already paid once per solve by `get_variables_linearity`. The
5679    ///   walk is also the only option for a model built through
5680    ///   [`NlProblem::from_expressions`], which has no header at all.
5681    ///
5682    /// The two disagree only in the safe direction, which
5683    /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5684    /// fixture corpus: the walked set always sits inside the header's
5685    /// prefix, because `parse_nl_text` folds constant `C` bodies away
5686    /// (`gh #492`) and AMPL's own census predates that fold.
5687    fn nonlinear_variables(&self) -> Vec<Index> {
5688        if let Some(c) = self.prob.nl_counts
5689            && c.nonlinear_vars() >= self.prob.n
5690        {
5691            return (0..self.prob.n as Index).collect();
5692        }
5693        self.nonlinear_var_set()
5694            .into_iter()
5695            .map(|i| i as Index)
5696            .collect()
5697    }
5698
5699    /// Mutable access to that same problem, for a caller that owns this
5700    /// TNLP outright.
5701    ///
5702    /// The tapes were built from the expressions in [`Self::problem`] and
5703    /// are not rebuilt, so editing an expression here does **not** change
5704    /// what this TNLP evaluates. It exists for teardown: the Python
5705    /// binding takes the expression trees out through here so a deeply
5706    /// nested one is dropped on a stack chosen for it rather than
5707    /// recursively on whatever thread collected the object (pounce#472).
5708    pub fn problem_mut(&mut self) -> &mut NlProblem {
5709        &mut self.prob
5710    }
5711
5712    /// Hessian-vector product of the Lagrangian:
5713    /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5714    ///
5715    /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5716    /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5717    /// compressed columns into the sparse lower triangle; here the seed is
5718    /// the caller's `v` directly, so it is a single forward-over-reverse
5719    /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5720    /// chromatic number. That is what makes it usable on models where
5721    /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5722    /// truncated-CG step only ever needs `∇²L · v`.
5723    ///
5724    /// Sign convention matches `eval_h` and the rest of this evaluator: a
5725    /// `maximize` model's objective is negated so the returned operator is
5726    /// the one that minimizing solves. `lambda` is `None` for the objective
5727    /// block alone.
5728    ///
5729    /// `out` is overwritten (not accumulated into). Errors on any length
5730    /// mismatch rather than panicking, since the Python binding hands this
5731    /// arbitrary user arrays.
5732    pub fn hessian_vector_product(
5733        &mut self,
5734        x: &[Number],
5735        v: &[Number],
5736        obj_factor: Number,
5737        lambda: Option<&[Number]>,
5738        out: &mut [Number],
5739    ) -> Result<(), String> {
5740        self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5741    }
5742
5743    /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5744    /// once, `out[:, c] = ∇²L · v[:, c]`.
5745    ///
5746    /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5747    /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5748    ///
5749    /// Worth having as its own entry point rather than a loop over the
5750    /// single-vector call: the forward sweep depends only on `x`, so a block
5751    /// runs it *once per tape* and reuses `vals` across all `k` directions,
5752    /// where `k` separate calls would redo it `k` times. Only the
5753    /// forward-tangent + reverse-over-tangent passes are per-direction. That
5754    /// is the shape a block-Krylov solve, a directional-derivative probe, or
5755    /// a densify-the-Hessian loop wants.
5756    ///
5757    /// An all-zero direction is skipped, so passing a sparse block whose
5758    /// columns are mostly empty costs only the columns that carry signal.
5759    /// (The sparsity that dominates is the model's own: each tape touches
5760    /// only its own variables, and `hessian_directional` is O(tape ops), not
5761    /// O(n).)
5762    pub fn hessian_vector_products(
5763        &mut self,
5764        x: &[Number],
5765        v: &[Number],
5766        k: usize,
5767        obj_factor: Number,
5768        lambda: Option<&[Number]>,
5769        out: &mut [Number],
5770    ) -> Result<(), String> {
5771        let (n, m) = (self.prob.n, self.prob.m);
5772        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5773            if got == want {
5774                Ok(())
5775            } else {
5776                Err(format!(
5777                    "hessian_vector_product: {name} has length {got}, expected {want}"
5778                ))
5779            }
5780        };
5781        check("x", x.len(), n)?;
5782        check("v", v.len(), n * k)?;
5783        check("out", out.len(), n * k)?;
5784        if let Some(lam) = lambda {
5785            check("lambda", lam.len(), m)?;
5786        }
5787
5788        out.fill(0.0);
5789        if k == 0 || n == 0 {
5790            return Ok(());
5791        }
5792
5793        // Which directions carry signal. Computed once, not per (tape,
5794        // direction) pair — with many small summand tapes the scan would
5795        // otherwise dominate the work it is meant to save. Reuses the
5796        // persistent mask so a Krylov loop allocates nothing per iteration.
5797        self.hvp_live.clear();
5798        self.hvp_live
5799            .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5800        if !self.hvp_live.iter().any(|&l| l) {
5801            return Ok(());
5802        }
5803
5804        let obj_seed = if self.prob.minimize {
5805            obj_factor
5806        } else {
5807            -obj_factor
5808        };
5809        // The constant blocks. `H · v` for a quadratic form is a matvec
5810        // against stored values, so unlike a tape it needs no forward sweep
5811        // and no `x` at all. Easy to forget — a matrix-free solve that
5812        // silently dropped the quadratic part of `∇²L` would still converge,
5813        // just to a different point by a different route, which is the
5814        // failure mode this series is most exposed to.
5815        if !self.quad.is_empty() {
5816            if obj_seed != 0.0 {
5817                if let Some(f) = self.quad.objective_form() {
5818                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5819                        if self.hvp_live[c] {
5820                            self.quad.add_hessian_vector(
5821                                f,
5822                                &v[c * n..(c + 1) * n],
5823                                obj_seed,
5824                                out_col,
5825                            );
5826                        }
5827                    }
5828                }
5829            }
5830            if let Some(lam) = lambda {
5831                for (i, &w) in lam.iter().enumerate() {
5832                    if w == 0.0 {
5833                        continue;
5834                    }
5835                    let Some(f) = self.quad.row_form(i) else {
5836                        continue;
5837                    };
5838                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5839                        if self.hvp_live[c] {
5840                            self.quad
5841                                .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5842                        }
5843                    }
5844                }
5845            }
5846        }
5847
5848        if obj_seed != 0.0 {
5849            for t in &self.obj_tapes {
5850                if t.ops.is_empty() {
5851                    continue;
5852                }
5853                // Once per tape, not once per direction — the whole point
5854                // of the block form.
5855                t.forward_into(x, &mut self.vals_scratch);
5856                for (c, out_col) in out.chunks_mut(n).enumerate() {
5857                    if !self.hvp_live[c] {
5858                        continue;
5859                    }
5860                    t.hessian_directional(
5861                        &self.vals_scratch,
5862                        &v[c * n..(c + 1) * n],
5863                        obj_seed,
5864                        out_col,
5865                        &mut self.dot_scratch,
5866                        &mut self.adj_scratch,
5867                        &mut self.adj_dot_scratch,
5868                    );
5869                }
5870            }
5871        }
5872
5873        if let Some(lam) = lambda {
5874            // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5875            // range for every k.
5876            for (i, &w) in lam.iter().enumerate() {
5877                if w == 0.0 {
5878                    continue;
5879                }
5880                for t in &self.con_tapes[i] {
5881                    if t.ops.is_empty() {
5882                        continue;
5883                    }
5884                    t.forward_into(x, &mut self.vals_scratch);
5885                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5886                        if !self.hvp_live[c] {
5887                            continue;
5888                        }
5889                        t.hessian_directional(
5890                            &self.vals_scratch,
5891                            &v[c * n..(c + 1) * n],
5892                            w,
5893                            out_col,
5894                            &mut self.dot_scratch,
5895                            &mut self.adj_scratch,
5896                            &mut self.adj_dot_scratch,
5897                        );
5898                    }
5899                }
5900            }
5901        }
5902
5903        Ok(())
5904    }
5905
5906    /// Clone this TNLP with per-instance overrides applied — the
5907    /// "one structure, many bound / starting-point variations" case of
5908    /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5909    /// coloring are reused via `Clone` (they depend only on the model
5910    /// structure, which a variation cannot change); only the values in
5911    /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5912    /// are replaced. Any stale `final_x` from a previous solve of
5913    /// `self` is cleared on the clone.
5914    ///
5915    /// Errors when an override's length does not match the model
5916    /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5917    pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5918        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5919            if got == want {
5920                Ok(())
5921            } else {
5922                Err(format!(
5923                    "NlVariation.{name} has length {got}, expected {want}"
5924                ))
5925            }
5926        };
5927        let mut out = self.clone();
5928        out.final_x = None;
5929        out.final_obj = 0.0;
5930        out.final_lambda = None;
5931        out.final_z_l = None;
5932        out.final_z_u = None;
5933        if let Some(x0) = &v.x0 {
5934            check("x0", x0.len(), self.prob.n)?;
5935            out.prob.x0.clone_from(x0);
5936        }
5937        if let Some(x_l) = &v.x_l {
5938            check("x_l", x_l.len(), self.prob.n)?;
5939            out.prob.x_l.clone_from(x_l);
5940        }
5941        if let Some(x_u) = &v.x_u {
5942            check("x_u", x_u.len(), self.prob.n)?;
5943            out.prob.x_u.clone_from(x_u);
5944        }
5945        if let Some(g_l) = &v.g_l {
5946            check("g_l", g_l.len(), self.prob.m)?;
5947            out.prob.g_l.clone_from(g_l);
5948        }
5949        if let Some(g_u) = &v.g_u {
5950            check("g_u", g_u.len(), self.prob.m)?;
5951            out.prob.g_u.clone_from(g_u);
5952        }
5953        Ok(out)
5954    }
5955
5956    /// Build one [`NlTnlp`] per variation, sharing this instance's
5957    /// structure (see [`Self::variant`]). Returns instances in input
5958    /// order; errors on the first length-mismatched variation.
5959    pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5960        vs.iter().map(|v| self.variant(v)).collect()
5961    }
5962}
5963
5964/// Per-instance overrides for building a family of related NLP
5965/// instances from one parsed `.nl` model (pounce#126): same structure
5966/// and tapes, different starting point and/or bounds — parametric
5967/// sweeps, multi-start, or branch-and-bound node relaxations where
5968/// each node only tightens variable bounds. `None` keeps the base
5969/// model's value.
5970#[derive(Debug, Clone, Default)]
5971pub struct NlVariation {
5972    pub x0: Option<Vec<Number>>,
5973    pub x_l: Option<Vec<Number>>,
5974    pub x_u: Option<Vec<Number>>,
5975    pub g_l: Option<Vec<Number>>,
5976    pub g_u: Option<Vec<Number>>,
5977}
5978
5979impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5980    /// Per-`.nl`-row constraint expression tape, with the linear
5981    /// part folded in. Returns `None` for constraints that contribute
5982    /// neither a nonlinear expression nor any linear coefficients
5983    /// (so FBBT skips them — there's nothing to tighten).
5984    fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5985        if i >= self.prob.con_nonlinear.len() {
5986            return None;
5987        }
5988        let nonlinear = self.prob.con_expr(i);
5989        let linear = self
5990            .prob
5991            .con_linear
5992            .get(i)
5993            .map(|v| v.as_slice())
5994            .unwrap_or(&[]);
5995        // FBBT needs the tree, so a recognized body is rebuilt here. It is
5996        // rebuilt once per row per presolve pass and dropped again, so the
5997        // DAG never all exists at once — which is the property the phase is
5998        // actually about. Translating the stored coefficients instead would
5999        // propagate bounds through a different association and change the
6000        // tightening; that is not a trade this phase makes.
6001        crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
6002    }
6003
6004    /// Variable name from the sibling `.col` file, if one was loaded.
6005    /// Index is original `.nl` column order.
6006    fn variable_name(&self, i: usize) -> Option<&str> {
6007        self.prob.var_names.get(i).map(String::as_str)
6008    }
6009
6010    /// Constraint name from the sibling `.row` file, if one was loaded.
6011    /// Index is original `.nl` row order.
6012    fn constraint_name(&self, i: usize) -> Option<&str> {
6013        self.prob.con_names.get(i).map(String::as_str)
6014    }
6015}
6016
6017/// Per-worker scratch for the parallel Hessian walk. Four buffers of
6018/// `max_tape_n` — the largest tape's node count, tens of entries on the models
6019/// this path is for — so a worker's copy costs about a kilobyte.
6020#[cfg(not(target_arch = "wasm32"))]
6021struct HessScratch {
6022    vals: Vec<f64>,
6023    dot: Vec<f64>,
6024    adj: Vec<f64>,
6025    adj_dot: Vec<f64>,
6026}
6027
6028/// Per-worker scratch for the parallel Jacobian walk: one gradient row of
6029/// length `n` plus the two small tape arenas `gradient_seed_into` fills.
6030#[cfg(not(target_arch = "wasm32"))]
6031#[derive(Debug, Clone)]
6032struct JacScratch {
6033    row_grad: Vec<f64>,
6034    vals: Vec<f64>,
6035    adj: Vec<f64>,
6036}
6037
6038#[cfg(not(target_arch = "wasm32"))]
6039impl JacScratch {
6040    fn new(n: usize, max_tape_n: usize) -> Self {
6041        Self {
6042            row_grad: vec![0.0; n],
6043            vals: vec![0.0; max_tape_n],
6044            adj: vec![0.0; max_tape_n],
6045        }
6046    }
6047}
6048
6049#[cfg(not(target_arch = "wasm32"))]
6050impl HessScratch {
6051    fn new(n: usize) -> Self {
6052        Self {
6053            vals: vec![0.0; n],
6054            dot: vec![0.0; n],
6055            adj: vec![0.0; n],
6056            adj_dot: vec![0.0; n],
6057        }
6058    }
6059}
6060
6061impl NlTnlp {
6062    /// Smallest (row, tape) × color work list that earns a parallel walk.
6063    /// Below it the rayon dispatch costs more than the sweeps it splits, and
6064    /// the serial tape-major walk is also cheaper per unit work because it
6065    /// forwards each tape once rather than once per color.
6066    ///
6067    /// Measured on a banded synthetic (`sin(x_i·x_{i+1}) + x_{i+1}²·x_{i+2}`
6068    /// per row, the model in `parallel_hessian_matches_serial.rs`), serial ->
6069    /// parallel with the gate forced open:
6070    ///
6071    /// | pairs | serial | parallel | ratio |
6072    /// |---|---|---|---|
6073    /// | 400 | 0.028 ms | 0.036 ms | 0.79x |
6074    /// | 1 200 | 0.073 ms | 0.066 ms | 1.11x |
6075    /// | 4 000 | 0.179 ms | 0.112 ms | 1.60x |
6076    /// | 12 000 | 0.344 ms | 0.203 ms | 1.69x |
6077    ///
6078    /// The crossover sits between 400 and 1 200, hence this value.
6079    ///
6080    /// **What that table does not say is how big the win gets.** That model
6081    /// has three Hessian colors, so it has three tasks to hand out however
6082    /// many cores are free, and 1.7x is near its ceiling. The parallelism
6083    /// available here *is* the color count: an AC-SCOPF model with 28 colors
6084    /// runs the same walk 4.9x faster (108.3 ms -> 22.2 ms at 54 859
6085    /// variables, 421.0 ms -> 89.0 ms at 209 755). Gating on pair count alone
6086    /// is therefore conservative in the safe direction — at equal pairs, more
6087    /// colors can only help.
6088    const HESS_PAR_MIN_PAIRS: usize = 1024;
6089
6090    /// Whether the flat Hessian walk runs color-major and in parallel.
6091    ///
6092    /// `POUNCE_NL_PARALLEL_EVAL` overrides the size gate: `0` forces the
6093    /// serial walk, `1` forces the parallel one whatever the size. The two
6094    /// produce bit-identical values, so this is a lever for measurement and
6095    /// for pinning a suspected parallelism bug, not a correctness switch —
6096    /// and `1` is how the threshold below was measured, since the gate would
6097    /// otherwise hide the small end of the curve.
6098    #[cfg(not(target_arch = "wasm32"))]
6099    fn hess_flat_parallel(&self) -> bool {
6100        // More than one color is a hard requirement, not a heuristic: with one
6101        // color there is one task and the inversion is pure overhead.
6102        let multi_color = self.compressed.len() > 1;
6103        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6104            Ok("0") | Ok("no") | Ok("false") => false,
6105            Ok("1") | Ok("yes") | Ok("true") => multi_color,
6106            _ => multi_color && self.hess_color_index.con_row.len() >= Self::HESS_PAR_MIN_PAIRS,
6107        }
6108    }
6109
6110    /// Smallest Jacobian nonzero count that earns a parallel walk. Same shape
6111    /// of trade as [`Self::HESS_PAR_MIN_PAIRS`], but a cheaper one to cross:
6112    /// the row walk is *not* reordered or duplicated, so the only cost is the
6113    /// dispatch and one length-`n` scratch row per worker.
6114    #[cfg(not(target_arch = "wasm32"))]
6115    const JAC_PAR_MIN_NNZ: usize = 4096;
6116
6117    /// Smallest row count that earns a parallel constraint evaluation. This is
6118    /// the cheapest of the three walks per row — one forward sweep, no
6119    /// gradient, no directional derivative — so the dispatch is a larger share
6120    /// of it and the threshold is the highest of the three.
6121    ///
6122    /// Measured on the banded synthetic, serial -> parallel with the gate
6123    /// forced open: 500 rows 0.34x, 2 000 0.84x, 5 000 1.07x, 8 000 1.53x,
6124    /// 20 000 3.19x, 60 000 4.97x, 200 000 5.78x. The crossover is near 5 000,
6125    /// so the gate sits just under it. Unlike the Hessian, this walk has no
6126    /// color ceiling — every row is its own task — which is why it keeps
6127    /// climbing to ~6x where the Hessian's 3-color model stalls at 1.7x.
6128    #[cfg(not(target_arch = "wasm32"))]
6129    const EVAL_G_PAR_MIN_ROWS: usize = 4096;
6130
6131    /// Whether `eval_g` runs in parallel over rows. Shares
6132    /// `POUNCE_NL_PARALLEL_EVAL` with the other two walks.
6133    #[cfg(not(target_arch = "wasm32"))]
6134    fn eval_g_parallel(&self) -> bool {
6135        // The shared-CSE arm returns earlier; this gate only ever sees the
6136        // flat path.
6137        let big = self.prob.m >= Self::EVAL_G_PAR_MIN_ROWS;
6138        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6139            Ok("0") | Ok("no") | Ok("false") => false,
6140            Ok("1") | Ok("yes") | Ok("true") => true,
6141            _ => big,
6142        }
6143    }
6144
6145    /// Whether the flat Jacobian walk runs in parallel over row groups.
6146    /// Shares `POUNCE_NL_PARALLEL_EVAL` with the Hessian.
6147    #[cfg(not(target_arch = "wasm32"))]
6148    fn jac_flat_parallel(&self) -> bool {
6149        let big = self.jac_nnz >= Self::JAC_PAR_MIN_NNZ;
6150        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6151            Ok("0") | Ok("no") | Ok("false") => false,
6152            Ok("1") | Ok("yes") | Ok("true") => true,
6153            _ => big,
6154        }
6155    }
6156
6157    /// The flat Jacobian, walked in parallel over contiguous groups of rows.
6158    ///
6159    /// Unlike the Hessian this needs no inversion: rows are already
6160    /// independent — each zeroes only its own columns of the gradient scratch,
6161    /// accumulates into them, and writes a contiguous run of `values`. So the
6162    /// work is split by *rows*, each group keeping the serial order inside
6163    /// itself, and every row computes exactly what it computed serially. The
6164    /// results are bit-identical for the stronger reason that nothing was
6165    /// reordered at all.
6166    ///
6167    /// Two things are per-worker rather than per-call: the length-`n` gradient
6168    /// row (the reason this is not simply `par_iter`, since `n` can be tens of
6169    /// thousands and allocating one per row would dwarf the work) and the two
6170    /// small tape arenas. They are pooled on `self` and reused across
6171    /// evaluations.
6172    #[cfg(not(target_arch = "wasm32"))]
6173    fn jac_flat_rows_parallel(&mut self, x_arg: Option<&[Number]>, values: &mut [Number]) {
6174        use rayon::prelude::*;
6175        let n = self.prob.n;
6176        let m = self.prob.m;
6177        let groups = rayon::current_num_threads().clamp(1, m.max(1));
6178        let max_tape_n = self.vals_scratch.len();
6179
6180        // Row -> offset of its run in `values`, so a group can be hnaded one
6181        // contiguous slice. Rebuilt only when the shape changes.
6182        if self.jac_row_off.len() != m + 1 {
6183            self.jac_row_off = Vec::with_capacity(m + 1);
6184            let mut acc = 0u32;
6185            for cols in &self.jac_cols {
6186                self.jac_row_off.push(acc);
6187                acc += cols.len() as u32;
6188            }
6189            self.jac_row_off.push(acc);
6190        }
6191        if self.jac_scratch_pool.len() != groups {
6192            self.jac_scratch_pool = (0..groups)
6193                .map(|_| JacScratch::new(n, max_tape_n))
6194                .collect();
6195        }
6196
6197        // Equal-row groups. Rows differ in width, so this is not a perfect
6198        // balance; rayon is given more groups than threads only when that is
6199        // free, and the measurement below is taken with this split.
6200        let per = m.div_ceil(groups.max(1));
6201        let Self {
6202            prob,
6203            con_tapes,
6204            quad,
6205            jac_cols,
6206            jac_row_off,
6207            jac_scratch_pool,
6208            ..
6209        } = self;
6210        let xs = x_arg.unwrap_or(&prob.x0);
6211
6212        // Hand each group its own contiguous run of `values`.
6213        let mut rest: &mut [Number] = values;
6214        let mut slices: Vec<&mut [Number]> = Vec::with_capacity(groups);
6215        for g in 0..groups {
6216            let end_row = ((g + 1) * per).min(m);
6217            let start_row = (g * per).min(m);
6218            let len = (jac_row_off[end_row] - jac_row_off[start_row]) as usize;
6219            let (head, tail) = rest.split_at_mut(len);
6220            slices.push(head);
6221            rest = tail;
6222        }
6223
6224        jac_scratch_pool
6225            .par_iter_mut()
6226            .zip(slices.into_par_iter())
6227            .enumerate()
6228            .for_each(|(g, (sc, out))| {
6229                let (lo, hi) = ((g * per).min(m), ((g + 1) * per).min(m));
6230                let JacScratch {
6231                    row_grad,
6232                    vals,
6233                    adj,
6234                } = sc;
6235                let mut k = 0usize;
6236                for i in lo..hi {
6237                    for &j in &jac_cols[i] {
6238                        row_grad[j] = 0.0;
6239                    }
6240                    for t in &con_tapes[i] {
6241                        t.gradient_seed_into(xs, 1.0, row_grad, vals, adj);
6242                    }
6243                    if let Some(f) = quad.row_form(i) {
6244                        quad.add_gradient(f, xs, 1.0, row_grad);
6245                    }
6246                    for &(v, c) in &prob.con_linear[i] {
6247                        row_grad[v] += c;
6248                    }
6249                    for &j in &jac_cols[i] {
6250                        out[k] = row_grad[j];
6251                        k += 1;
6252                    }
6253                }
6254            });
6255    }
6256
6257    /// The flat Hessian's constraint block, walked color-major with one rayon
6258    /// task per color.
6259    ///
6260    /// Each task owns its color's `compressed[c]` outright, so nothing is
6261    /// shared mutably and no reduction is needed. Within a color the pairs are
6262    /// visited in ascending (row, tape) order — the order the serial walk
6263    /// contributes them in — so the sums are identical term by term and the
6264    /// two paths agree bit-for-bit, which
6265    /// `parallel_matches_serial_bit_for_bit` pins.
6266    ///
6267    /// The cost of the inversion is forwarding a tape once per color it
6268    /// touches instead of once for all of them (2.9 colors per tape measured
6269    /// on AC-SCOPF). That is paid back by the parallelism and only above
6270    /// `HESS_PAR_MIN_PAIRS`.
6271    #[cfg(not(target_arch = "wasm32"))]
6272    fn hess_flat_colors_parallel(&mut self, x_arg: Option<&[Number]>, lam: &[Number]) {
6273        use rayon::prelude::*;
6274        let Self {
6275            compressed,
6276            seeds,
6277            con_tapes,
6278            hess_color_index: idx,
6279            vals_scratch,
6280            prob,
6281            ..
6282        } = self;
6283        let x = x_arg.unwrap_or(&prob.x0);
6284        let max_tape_n = vals_scratch.len();
6285        compressed.par_iter_mut().enumerate().for_each_init(
6286            || HessScratch::new(max_tape_n),
6287            |sc, (c, out)| {
6288                let (rows, tapes) = idx.con_pairs(c);
6289                let seed = &seeds[c];
6290                let HessScratch {
6291                    vals,
6292                    dot,
6293                    adj,
6294                    adj_dot,
6295                } = sc;
6296                for (&k, &ti) in rows.iter().zip(tapes.iter()) {
6297                    let w = lam[k as usize];
6298                    if w == 0.0 {
6299                        continue;
6300                    }
6301                    let t = &con_tapes[k as usize][ti as usize];
6302                    if t.ops.is_empty() {
6303                        continue;
6304                    }
6305                    t.forward_into(x, vals);
6306                    t.hessian_directional(vals, seed, w, out, dot, adj, adj_dot);
6307                }
6308            },
6309        );
6310    }
6311}
6312
6313impl TNLP for NlTnlp {
6314    /// An `.nl` model carries no callback: the CLI and the browser build
6315    /// read the iteration log, not `IterStats` (gh#965).
6316    fn wants_intermediate_callback(&self) -> bool {
6317        false
6318    }
6319
6320    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
6321        Some(NlpInfo {
6322            n: self.prob.n as Index,
6323            m: self.prob.m as Index,
6324            nnz_jac_g: self.jac_nnz as Index,
6325            nnz_h_lag: self.h_irow.len() as Index,
6326            index_style: IndexStyle::C,
6327        })
6328    }
6329
6330    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
6331        b.x_l.copy_from_slice(&self.prob.x_l);
6332        b.x_u.copy_from_slice(&self.prob.x_u);
6333        if !self.prob.g_l.is_empty() {
6334            b.g_l.copy_from_slice(&self.prob.g_l);
6335            b.g_u.copy_from_slice(&self.prob.g_u);
6336        }
6337        true
6338    }
6339
6340    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
6341        sp.x.copy_from_slice(&self.prob.x0);
6342        // The `.nl` `d` segment supplies initial constraint multipliers
6343        // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
6344        // the engine when `warm_start_init_point yes` — by handing them
6345        // back; `OrigIpoptNlp::get_starting_point` then compresses them into
6346        // the algorithm-side y_c / y_d. Without this the warm start silently
6347        // began from zero multipliers, discarding the parsed duals. (Code
6348        // review 2026-06 item M19.)
6349        if sp.init_lambda {
6350            sp.lambda.copy_from_slice(&self.prob.lambda0);
6351        }
6352        // Bound multipliers come from the `ipopt_zL_in` / `ipopt_zU_in`
6353        // variable suffixes, as in Ipopt's `AmplTNLP::get_starting_point`:
6354        // `z_L = sigma * zL_in`, `z_U = -sigma * zU_in` (sigma = -1 for a
6355        // maximize model) — the inverse of the CLI's `ipopt_zL_out` /
6356        // `ipopt_zU_out`, so a `.sol` result fed back in round-trips. A
6357        // suffix the file does not carry leaves that block to the engine's
6358        // defaults, as before.
6359        if sp.init_z {
6360            let sigma = if self.prob.minimize { 1.0 } else { -1.0 };
6361            let sfx = &self.prob.suffixes;
6362            for (name, out, sign) in [
6363                ("ipopt_zL_in", &mut *sp.z_l, sigma),
6364                ("ipopt_zU_in", &mut *sp.z_u, -sigma),
6365            ] {
6366                // A writer may emit an all-integer suffix as integer-kind.
6367                let vals: Option<Vec<Number>> = sfx.var_real.get(name).cloned().or_else(|| {
6368                    sfx.var_int
6369                        .get(name)
6370                        .map(|v| v.iter().map(|&x| x as Number).collect())
6371                });
6372                if let Some(v) = vals.filter(|v| v.len() == out.len()) {
6373                    for (o, x) in out.iter_mut().zip(v) {
6374                        *o = sign * x;
6375                    }
6376                }
6377            }
6378        }
6379        true
6380    }
6381
6382    /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
6383    /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
6384    /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
6385    /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
6386    /// writes into. Before gh#483 nothing implemented this callback for
6387    /// `.nl` input, so a tagged model reached the solver with the option
6388    /// accepted and *no* scaling applied, silently.
6389    ///
6390    /// Returns `false` (engine falls back to no scaling) when the file
6391    /// declares no `scaling_factor` suffix at all — the same "user
6392    /// supplied nothing" answer as the default `TNLP` impl.
6393    ///
6394    /// AMPL suffix vectors default to **0** for components the model did
6395    /// not tag, and 0 is not a usable scale factor. A zero entry is
6396    /// therefore read as "not tagged" and becomes 1.0, which is what
6397    /// "unlisted components are unscaled" means. Per-variable factors
6398    /// are passed straight through: `OrigIpoptNlp` does not model them
6399    /// and refuses the solve with a message rather than dropping them.
6400    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
6401        const NAME: &str = "scaling_factor";
6402        let sfx = &self.prob.suffixes;
6403        let obj = sfx.obj_real.get(NAME);
6404        let var = sfx.var_real.get(NAME);
6405        let con = sfx.con_real.get(NAME);
6406        let computed = self.curvature_scaling.as_ref();
6407        if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
6408            return false;
6409        }
6410        // Curvature-based factors (gh #703) are the *base*; a
6411        // `scaling_factor` suffix the model actually carries overrides
6412        // them component by component, because an explicit factor from the
6413        // modeller beats one inferred from the coefficients. A model with
6414        // no suffixes is the ordinary case and gets the computed vectors
6415        // whole.
6416        if let Some(sc) = computed {
6417            // The length guards are a `copy_from_slice` panic guard, not a
6418            // policy: `curvature_scaling` sizes both vectors from the same
6419            // `NlProblem` this callback is answering for, so a mismatch is a
6420            // bug upstream, not a model the scheme declines. Declining is the
6421            // *worst* available response to it — `use_*_scaling` stays false,
6422            // the engine reads that as "user supplied nothing", and the run
6423            // proceeds unscaled with the option accepted, which is gh #483
6424            // again. Assert it in debug builds so a mismatch is found here,
6425            // where it is one line, instead of as a slow solve later.
6426            debug_assert_eq!(
6427                sc.x.len(),
6428                req.x_scaling.len(),
6429                "curvature x-scaling sized {} for a {}-variable request",
6430                sc.x.len(),
6431                req.x_scaling.len()
6432            );
6433            debug_assert_eq!(
6434                sc.g.len(),
6435                req.g_scaling.len(),
6436                "curvature g-scaling sized {} for a {}-row request",
6437                sc.g.len(),
6438                req.g_scaling.len()
6439            );
6440            if sc.x.len() == req.x_scaling.len() {
6441                req.x_scaling.copy_from_slice(&sc.x);
6442                *req.use_x_scaling = true;
6443            }
6444            if sc.g.len() == req.g_scaling.len() {
6445                req.g_scaling.copy_from_slice(&sc.g);
6446                *req.use_g_scaling = true;
6447            }
6448        }
6449        // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
6450        // are parsed and ignored), so its entry is the objective scale.
6451        *req.obj_scaling = obj
6452            .and_then(|v| v.first().copied())
6453            .filter(|&s| s != 0.0)
6454            .unwrap_or(1.0);
6455        // A zero entry is AMPL's "untagged" default, not a scale factor.
6456        // An untagged component therefore falls back to the base: the
6457        // curvature factor when one was computed, and an explicit 1.0
6458        // otherwise — explicit because the callback's contract is to fill
6459        // the buffer, not to assume the caller pre-filled it with ones.
6460        if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
6461            for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
6462                if s != 0.0 {
6463                    *slot = s;
6464                } else if computed.is_none() {
6465                    *slot = 1.0;
6466                }
6467            }
6468            *req.use_x_scaling = true;
6469        } else if computed.is_none() {
6470            *req.use_x_scaling = false;
6471        }
6472        if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
6473            for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
6474                if s != 0.0 {
6475                    *slot = s;
6476                } else if computed.is_none() {
6477                    *slot = 1.0;
6478                }
6479            }
6480            *req.use_g_scaling = true;
6481        } else if computed.is_none() {
6482            *req.use_g_scaling = false;
6483        }
6484        true
6485    }
6486
6487    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
6488        // Reuse the shared forward-value arena (sized to `max_tape_n`) so
6489        // each summand sweep allocates nothing — see `Tape::eval_into`.
6490        let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
6491        let mut nl: Number = 0.0;
6492        for t in obj_tapes {
6493            nl += t.eval_into(x, vals);
6494        }
6495        // A recognized objective has no tapes, so the loop above added
6496        // nothing and the form supplies the whole nonlinear part — including
6497        // the linear and constant terms AMPL folded into that tree, which is
6498        // why they are *not* also in `obj_linear` / `obj_constant`.
6499        if let Some(f) = self.quad.objective_form() {
6500            nl += self.quad.value(f, x);
6501        }
6502        let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
6503        let v = self.prob.obj_constant + nl + lin;
6504        let signed = if self.prob.minimize { v } else { -v };
6505        Some(signed)
6506    }
6507
6508    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
6509        grad.fill(0.0);
6510        // Reuse the forward-value / adjoint scratch arenas (sized to
6511        // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
6512        // nothing — see `Tape::gradient_seed_into` (M18).
6513        for t in &self.obj_tapes {
6514            t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
6515        }
6516        if let Some(f) = self.quad.objective_form() {
6517            self.quad.add_gradient(f, x, 1.0, grad);
6518        }
6519        for (i, c) in &self.prob.obj_linear {
6520            grad[*i] += c;
6521        }
6522        if !self.prob.minimize {
6523            for g in grad.iter_mut() {
6524                *g = -*g;
6525            }
6526        }
6527        true
6528    }
6529
6530    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
6531        // Constraint values are the line search's inner loop: on a
6532        // constraint-heavy model (m >> n) this runs ~10x per iteration over
6533        // every summand tape in the problem. Reuse the shared forward-value
6534        // arena so the sweep allocates nothing — the per-summand `Vec` the
6535        // allocating `Tape::eval` used to build was ~20% of `eval_g` on
6536        // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
6537        // `Tape::eval_into`.
6538        let m = self.prob.m;
6539        let con_linear = &self.prob.con_linear;
6540        let quad = &self.quad;
6541        if let Some(h) = &mut self.con_hybrid {
6542            // Shared CSE bodies once for the whole constraint block, then one
6543            // local sweep per summand (pounce#476).
6544            let ConHybrid {
6545                tape,
6546                row_start,
6547                prelude_vals,
6548                local_vals,
6549                ..
6550            } = h;
6551            tape.forward_prelude(x, prelude_vals);
6552            for i in 0..m {
6553                let mut nl: Number = 0.0;
6554                for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6555                    tape.forward_summand(s, x, prelude_vals, local_vals);
6556                    nl += tape.root_value(s, local_vals);
6557                }
6558                if let Some(f) = quad.row_form(i) {
6559                    nl += quad.value(f, x);
6560                }
6561                let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6562                g[i] = nl + lin;
6563            }
6564            return true;
6565        }
6566        // Row `i` reads only `x` and writes only `g[i]`, so this one is
6567        // parallel as written — no inversion, no row-offset bookkeeping, and
6568        // the per-row arithmetic is untouched, which is why the values are
6569        // bit-identical. Only the forward-value arena is per-worker.
6570        #[cfg(not(target_arch = "wasm32"))]
6571        if self.eval_g_parallel() {
6572            use rayon::prelude::*;
6573            let max_tape_n = self.vals_scratch.len();
6574            let con_tapes = &self.con_tapes;
6575            g.par_iter_mut().enumerate().for_each_init(
6576                || vec![0.0; max_tape_n],
6577                |vals, (i, gi)| {
6578                    let mut nl: Number = 0.0;
6579                    for t in &con_tapes[i] {
6580                        nl += t.eval_into(x, vals);
6581                    }
6582                    if let Some(f) = quad.row_form(i) {
6583                        nl += quad.value(f, x);
6584                    }
6585                    let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6586                    *gi = nl + lin;
6587                },
6588            );
6589            return true;
6590        }
6591        let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
6592        for i in 0..m {
6593            let mut nl: Number = 0.0;
6594            for t in &con_tapes[i] {
6595                nl += t.eval_into(x, vals);
6596            }
6597            // A quadratic row's summand range is empty above, so this is its
6598            // whole nonlinear part: one matvec against a constant matrix in
6599            // place of a walk over every monomial's tape.
6600            if let Some(f) = quad.row_form(i) {
6601                nl += quad.value(f, x);
6602            }
6603            let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6604            g[i] = nl + lin;
6605        }
6606        true
6607    }
6608
6609    fn eval_jac_g(
6610        &mut self,
6611        x: Option<&[Number]>,
6612        _new_x: bool,
6613        mode: SparsityRequest<'_>,
6614    ) -> bool {
6615        match mode {
6616            SparsityRequest::Structure { irow, jcol } => {
6617                let mut k = 0;
6618                for i in 0..self.prob.m {
6619                    for &j in &self.jac_cols[i] {
6620                        irow[k] = i as Index;
6621                        jcol[k] = j as Index;
6622                        k += 1;
6623                    }
6624                }
6625                true
6626            }
6627            SparsityRequest::Values { values } => {
6628                let n = self.prob.n;
6629                if self.scratch_row_grad.len() < n {
6630                    self.scratch_row_grad.resize(n, 0.0);
6631                }
6632                // Rows are independent and write disjoint runs of `values`, so
6633                // the parallel walk is the same work in the same order, split
6634                // by row group. Only the flat path: the shared-CSE arm below
6635                // forwards one prelude for the whole block, which is exactly
6636                // the sharing a row split would have to undo.
6637                #[cfg(not(target_arch = "wasm32"))]
6638                if self.con_hybrid.as_ref().filter(|h| h.use_for_jac).is_none()
6639                    && self.jac_flat_parallel()
6640                {
6641                    self.jac_flat_rows_parallel(x, values);
6642                    return true;
6643                }
6644                let Self {
6645                    prob,
6646                    con_tapes,
6647                    con_hybrid,
6648                    quad,
6649                    jac_cols,
6650                    scratch_row_grad,
6651                    vals_scratch,
6652                    adj_scratch,
6653                    ..
6654                } = self;
6655                let xs = x.unwrap_or(&prob.x0);
6656                let mut k = 0;
6657                // Shared-CSE path: the forward sweep over the CSE bodies runs
6658                // once for the whole constraint block instead of once per
6659                // referencing summand. The reverse sweep cannot be shared —
6660                // each row needs its own gradient — so a summand still walks
6661                // its own `prelude_reach` backwards.
6662                if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
6663                    let ConHybrid {
6664                        tape,
6665                        row_start,
6666                        prelude_vals,
6667                        local_vals,
6668                        local_adj,
6669                        prelude_adj,
6670                        ..
6671                    } = h;
6672                    tape.forward_prelude(xs, prelude_vals);
6673                    for i in 0..prob.m {
6674                        for &j in &jac_cols[i] {
6675                            scratch_row_grad[j] = 0.0;
6676                        }
6677                        for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6678                            tape.forward_summand(s, xs, prelude_vals, local_vals);
6679                            tape.gradient_summand(
6680                                s,
6681                                prelude_vals,
6682                                local_vals,
6683                                1.0,
6684                                scratch_row_grad,
6685                                local_adj,
6686                                prelude_adj,
6687                            );
6688                        }
6689                        if let Some(f) = quad.row_form(i) {
6690                            quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6691                        }
6692                        for &(v, c) in &prob.con_linear[i] {
6693                            scratch_row_grad[v] += c;
6694                        }
6695                        for &j in &jac_cols[i] {
6696                            values[k] = scratch_row_grad[j];
6697                            k += 1;
6698                        }
6699                    }
6700                    return true;
6701                }
6702                for i in 0..prob.m {
6703                    for &j in &jac_cols[i] {
6704                        scratch_row_grad[j] = 0.0;
6705                    }
6706                    for t in &con_tapes[i] {
6707                        // Allocation-free reverse-AD per summand tape (M18):
6708                        // reuse the shared forward/adjoint scratch arenas.
6709                        t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
6710                    }
6711                    // `Hx + a` for a quadratic row: one matvec over the
6712                    // row's support, in place of a reverse sweep per
6713                    // monomial. `jac_cols[i]` already covers the form's
6714                    // gradient support, so the scatter lands inside the
6715                    // window zeroed above.
6716                    if let Some(f) = quad.row_form(i) {
6717                        quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6718                    }
6719                    for &(v, c) in &prob.con_linear[i] {
6720                        scratch_row_grad[v] += c;
6721                    }
6722                    for &j in &jac_cols[i] {
6723                        values[k] = scratch_row_grad[j];
6724                        k += 1;
6725                    }
6726                }
6727                true
6728            }
6729        }
6730    }
6731
6732    fn eval_h(
6733        &mut self,
6734        x: Option<&[Number]>,
6735        _new_x: bool,
6736        obj_factor: Number,
6737        lambda: Option<&[Number]>,
6738        _new_lambda: bool,
6739        mode: SparsityRequest<'_>,
6740    ) -> bool {
6741        match mode {
6742            SparsityRequest::Structure { irow, jcol } => {
6743                irow.copy_from_slice(&self.h_irow);
6744                jcol.copy_from_slice(&self.h_jcol);
6745                true
6746            }
6747            SparsityRequest::Values { values } => {
6748                // Kept alongside the resolved slice: the parallel walk below
6749                // resolves it again *after* destructuring `self`, so `x` and
6750                // the buffers it writes borrow different fields and neither
6751                // has to be copied per evaluation.
6752                let x_arg = x;
6753                let x = x_arg.unwrap_or(&self.prob.x0);
6754                values.fill(0.0);
6755
6756                let obj_seed = if self.prob.minimize {
6757                    obj_factor
6758                } else {
6759                    -obj_factor
6760                };
6761                // Coloring path. For each (tape, weight) we do
6762                // one forward pass into `vals_scratch`, then one
6763                // forward-tangent+reverse-over-tangent per color
6764                // touched by that tape. Each pass accumulates a
6765                // weighted contribution of (H_tape · seed_c) into
6766                // `compressed[c]`. After all tapes done, we
6767                // decode each color's compressed vector into the
6768                // sparse `values` array.
6769                for buf in &mut self.compressed {
6770                    buf.fill(0.0);
6771                }
6772
6773                // The constant blocks first: no forward sweep, no
6774                // directional product, no decode — the multipliers are the
6775                // only thing that changed since the model was read, so this
6776                // is one `values[slot] += w · h` pass per live form. Skipped
6777                // wholesale on a model with nothing recognized, so such a
6778                // model does not pay an `O(m)` scan for a structure that is
6779                // empty.
6780                if !self.quad.is_empty() {
6781                    if obj_seed != 0.0 {
6782                        if let Some(f) = self.quad.objective_form() {
6783                            self.quad.accumulate_hessian(f, obj_seed, values);
6784                        }
6785                    }
6786                    if let Some(lam) = lambda {
6787                        for i in 0..self.prob.m {
6788                            let w = lam[i];
6789                            if w == 0.0 {
6790                                continue;
6791                            }
6792                            if let Some(f) = self.quad.row_form(i) {
6793                                self.quad.accumulate_hessian(f, w, values);
6794                            }
6795                        }
6796                    }
6797                }
6798
6799                if obj_seed != 0.0 {
6800                    for (ti, t) in self.obj_tapes.iter().enumerate() {
6801                        if t.ops.is_empty() {
6802                            continue;
6803                        }
6804                        t.forward_into(x, &mut self.vals_scratch);
6805                        for &c in &self.obj_tape_colors[ti] {
6806                            t.hessian_directional(
6807                                &self.vals_scratch,
6808                                &self.seeds[c as usize],
6809                                obj_seed,
6810                                &mut self.compressed[c as usize],
6811                                &mut self.dot_scratch,
6812                                &mut self.adj_scratch,
6813                                &mut self.adj_dot_scratch,
6814                            );
6815                        }
6816                    }
6817                }
6818
6819                // Computed before the match: its scrutinee takes `self` mutably,
6820                // so a guard cannot also read `self`. Absent on wasm32 along
6821                // with the arm it guards — the arm still has to *typecheck*
6822                // there, dead or not, and the method it calls does not exist.
6823                #[cfg(not(target_arch = "wasm32"))]
6824                let hess_flat_parallel = self.hess_flat_parallel();
6825                match (lambda, self.con_hybrid.as_mut()) {
6826                    // Shared-CSE path (issue #557). Per color the prelude's
6827                    // second-order work runs ONCE for the whole constraint
6828                    // block: one forward tangent, then — because
6829                    // reverse-over-tangent is linear in its adjoint seeds —
6830                    // one unit-weight reverse sweep over the λ-weighted
6831                    // adjoints accumulated by every summand of that color.
6832                    // The flat path below repeats both sweeps over the
6833                    // inlined CSE body once per referencing summand.
6834                    (Some(lam), Some(h)) if h.use_for_hess => {
6835                        let ConHybrid {
6836                            tape,
6837                            prelude_vals,
6838                            local_vals_all,
6839                            local_off,
6840                            summand_row,
6841                            hess_color_summands,
6842                            hess_color_reach,
6843                            hess_color_reach_off,
6844                            prelude_dot,
6845                            hess_prelude_adj,
6846                            prelude_adj_dot,
6847                            local_dot,
6848                            local_adj,
6849                            local_adj_dot,
6850                            ..
6851                        } = h;
6852                        // Forward once (values are color-independent):
6853                        // prelude for the block, then each summand of a row
6854                        // with a live multiplier into its packed slice.
6855                        tape.forward_prelude(x, prelude_vals);
6856                        for (si, s) in tape.summands.iter().enumerate() {
6857                            if lam[summand_row[si] as usize] == 0.0 {
6858                                continue;
6859                            }
6860                            tape.forward_summand(
6861                                s,
6862                                x,
6863                                prelude_vals,
6864                                &mut local_vals_all[local_off[si]..local_off[si + 1]],
6865                            );
6866                        }
6867                        for (c, list) in hess_color_summands.iter().enumerate() {
6868                            if !list
6869                                .iter()
6870                                .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
6871                            {
6872                                continue;
6873                            }
6874                            let seed = &self.seeds[c];
6875                            let out = &mut self.compressed[c];
6876                            let creach = &hess_color_reach
6877                                [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
6878                            tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
6879                            for &si in list {
6880                                let si = si as usize;
6881                                let w = lam[summand_row[si] as usize];
6882                                if w == 0.0 {
6883                                    continue;
6884                                }
6885                                tape.hessian_summand_directional(
6886                                    &tape.summands[si],
6887                                    &local_vals_all[local_off[si]..local_off[si + 1]],
6888                                    prelude_dot,
6889                                    seed,
6890                                    w,
6891                                    out,
6892                                    local_dot,
6893                                    local_adj,
6894                                    local_adj_dot,
6895                                    hess_prelude_adj,
6896                                    prelude_adj_dot,
6897                                );
6898                            }
6899                            tape.prelude_reverse_directional(
6900                                prelude_vals,
6901                                prelude_dot,
6902                                creach,
6903                                out,
6904                                hess_prelude_adj,
6905                                prelude_adj_dot,
6906                            );
6907                        }
6908                    }
6909                    // Color-major and parallel when the work is big enough
6910                    // to pay for the dispatch; bit-identical to the walk
6911                    // below, which stays the path every small model takes.
6912                    #[cfg(not(target_arch = "wasm32"))]
6913                    (Some(lam), _) if hess_flat_parallel => {
6914                        self.hess_flat_colors_parallel(x_arg, lam);
6915                    }
6916                    (Some(lam), _) => {
6917                        for k in 0..self.prob.m {
6918                            let w = lam[k];
6919                            if w == 0.0 {
6920                                continue;
6921                            }
6922                            for (ti, t) in self.con_tapes[k].iter().enumerate() {
6923                                if t.ops.is_empty() {
6924                                    continue;
6925                                }
6926                                t.forward_into(x, &mut self.vals_scratch);
6927                                for &c in &self.con_tape_colors[k][ti] {
6928                                    t.hessian_directional(
6929                                        &self.vals_scratch,
6930                                        &self.seeds[c as usize],
6931                                        w,
6932                                        &mut self.compressed[c as usize],
6933                                        &mut self.dot_scratch,
6934                                        &mut self.adj_scratch,
6935                                        &mut self.adj_dot_scratch,
6936                                    );
6937                                }
6938                            }
6939                        }
6940                    }
6941                    (None, _) => {}
6942                }
6943
6944                // Decode each color's compressed Hessian-vector
6945                // result into the lower-triangle `values` array.
6946                for (c, table) in self.decoding.iter().enumerate() {
6947                    let comp = &self.compressed[c];
6948                    for w in table {
6949                        values[w.hess_idx as usize] += comp[w.row as usize];
6950                    }
6951                }
6952                true
6953            }
6954        }
6955    }
6956
6957    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
6958        self.final_x = Some(sol.x.to_vec());
6959        self.final_obj = sol.obj_value;
6960        self.final_lambda = Some(sol.lambda.to_vec());
6961        self.final_z_l = Some(sol.z_l.to_vec());
6962        self.final_z_u = Some(sol.z_u.to_vec());
6963    }
6964
6965    /// Publish the `.col` / `.row` names (captured at load time) under the
6966    /// conventional `idx_names` metadata key, in original `.nl` order. The
6967    /// adapter permutes these into split space (see
6968    /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
6969    /// near-singular Jacobian row as the `mass_balance` equation rather
6970    /// than "row 3" — the model-vs-index gap Lee et al. (2024,
6971    /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
6972    /// model debugging. Declines (returns false) when the model shipped no
6973    /// name files so callers fall back to index labels.
6974    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
6975        let mut any = false;
6976        if !self.prob.var_names.is_empty() {
6977            var.strings
6978                .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
6979            any = true;
6980        }
6981        if !self.prob.con_names.is_empty() {
6982            con.strings
6983                .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
6984            any = true;
6985        }
6986        any
6987    }
6988
6989    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
6990        // A row is linear iff its nonlinear-part expression is the
6991        // identity zero — either left over from initial allocation ("no
6992        // `C<idx>` segment touched this row") or installed by the
6993        // constant-row-body fold in `parse_nl_text`, which shifts a
6994        // variable-free `C<idx>` body into the row bounds precisely so
6995        // that this test is a genuine linearity test and not just an
6996        // identity check (`gh #492`).
6997        for (i, t) in types.iter_mut().enumerate() {
6998            *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
6999                Linearity::Linear
7000            } else {
7001                Linearity::NonLinear
7002            };
7003        }
7004        true
7005    }
7006
7007    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7008        // Global linearity, per the upstream TNLP contract: a variable is
7009        // NonLinear iff it appears in the nonlinear part of the objective
7010        // or of any constraint; otherwise Linear. The parsed `.nl` splits
7011        // every row into a linear part (J/G coefficient list) and a
7012        // nonlinear expression, so the set of nonlinear variables is
7013        // exactly the structural union of `collect_vars` over
7014        // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
7015        // only by a linear part — or not referenced at all — is Linear.
7016        //
7017        let nonlinear = self.nonlinear_var_set();
7018        for (i, t) in types.iter_mut().enumerate() {
7019            *t = if nonlinear.contains(&i) {
7020                Linearity::NonLinear
7021            } else {
7022                Linearity::Linear
7023            };
7024        }
7025        true
7026    }
7027
7028    fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7029        // Objective-scoped variant of `get_variables_linearity`: only
7030        // `obj_nonlinear` contributes. This is what engages the presolve
7031        // auxiliary-elimination safeguard (pounce-presolve H11): a variable
7032        // that is nonlinear in the objective but happens to have a zero
7033        // gradient at the single probe point (e.g. `f = (x - x0)^2`
7034        // warm-started at `x0`) is kept in the objective support instead of
7035        // being mis-classified objective-free and eliminated. A variable
7036        // that is nonlinear only in *constraints* stays `Linear` here, so
7037        // the guard does not block legitimate eliminations of
7038        // objective-free equality blocks (the gas-network case).
7039        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
7040        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
7041        for (i, t) in types.iter_mut().enumerate() {
7042            *t = if nonlinear.contains(&i) {
7043                Linearity::NonLinear
7044            } else {
7045                Linearity::Linear
7046            };
7047        }
7048        true
7049    }
7050
7051    fn get_number_of_nonlinear_variables(&mut self) -> Index {
7052        self.nonlinear_variables().len() as Index
7053    }
7054
7055    fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
7056        let list = self.nonlinear_variables();
7057        if pos_nonlin_vars.len() < list.len() {
7058            return false;
7059        }
7060        pos_nonlin_vars[..list.len()].copy_from_slice(&list);
7061        true
7062    }
7063
7064    fn derivative_proofs(&mut self) -> DerivativeProofs {
7065        // Degree is the whole argument (gh #588, Q6). A body proved
7066        // degree ≤ 1 has a constant gradient and no second derivative; a
7067        // body proved degree 2 has a nonzero second derivative, hence a
7068        // gradient that moves. A body the recognizer refuses is
7069        // `Unknown` — and `Unknown` must stay `Unknown`, because the
7070        // refusal is structural, not a finding of nonlinearity.
7071        fn proof(affine: Option<bool>) -> DerivativeProof {
7072            match affine {
7073                Some(true) => DerivativeProof::Constant,
7074                Some(false) => DerivativeProof::Varying,
7075                None => DerivativeProof::Unknown,
7076            }
7077        }
7078        let obj_affine = self.prob.obj_nonlinear.provably_affine();
7079        let jac: Vec<DerivativeProof> = self
7080            .prob
7081            .con_nonlinear
7082            .iter()
7083            .map(|b| proof(b.provably_affine()))
7084            .collect();
7085
7086        // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
7087        // makes `∇²L` a non-constant function of `λ` — this is the QCQP
7088        // case, and it is exactly the assertion Ipopt would honour and
7089        // get wrong (§4 Lever 2). Otherwise every row must be *proved*
7090        // affine and the objective *proved* degree ≤ 2, at which point
7091        // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
7092        // reuse on `σ` because the restoration phase passes a different
7093        // one.
7094        let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
7095            DerivativeProof::Varying
7096        } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
7097            DerivativeProof::Constant
7098        } else {
7099            DerivativeProof::Unknown
7100        };
7101
7102        DerivativeProofs {
7103            grad_f: proof(obj_affine),
7104            hessian,
7105            jac,
7106        }
7107    }
7108}
7109
7110/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
7111pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
7112    let prob = read_nl_file(path)?;
7113    Ok(Rc::new(RefCell::new(NlTnlp::try_new(prob)?)))
7114}
7115
7116#[cfg(test)]
7117mod tests {
7118    /// `.nl` text for `min sum_i exp(x_i) + x_i^2` whose sum is a left-deep
7119    /// chain of `2n - 1` binary `o0`, wrapped in `wraps` pairs of unary `o16`
7120    /// (negation, an even count so the value is unchanged). The `o0` chain is
7121    /// read as one n-ary `Sum`; the negations are what stays genuinely deep.
7122    fn deep_sum_nl(n: usize, wraps: usize) -> String {
7123        let mut l = vec![
7124            "g3 1 1 0".to_string(),
7125            format!(" {n} 1 1 0 1"),
7126            " 0 1".into(),
7127            " 0 0".into(),
7128            format!(" 0 {n} 0"),
7129            " 0 0 0 1 0".into(),
7130            " 0 0 0 0 0".into(),
7131            format!(" {n} {n}"),
7132            " 0 0".into(),
7133            " 0 0 0 0 0".into(),
7134            "C0".into(),
7135            "n0".into(),
7136            "O0 0".into(),
7137        ];
7138        l.extend(std::iter::repeat_n("o16".to_string(), 2 * wraps));
7139        l.extend(std::iter::repeat_n("o0".to_string(), 2 * n - 1));
7140        for i in 0..n {
7141            l.extend([
7142                "o44".into(),
7143                format!("v{i}"),
7144                "o2".into(),
7145                format!("v{i}"),
7146                format!("v{i}"),
7147            ]);
7148        }
7149        l.extend(["r".into(), "4 0.5".into(), "b".into()]);
7150        l.extend((0..n).map(|_| "0 -1.0 1.0".to_string()));
7151        l.push(format!("k{}", n - 1));
7152        l.extend((0..n - 1).map(|i| (i + 1).to_string()));
7153        l.push(format!("J0 {n}"));
7154        l.extend((0..n).map(|i| format!("{i} 1.0")));
7155        l.push(format!("G0 {n}"));
7156        l.extend((0..n).map(|i| format!("{i} 0.0")));
7157        l.join("\n") + "\n"
7158    }
7159
7160    /// `ralph1` written as AMPL would: `min 2x - y, x >= 0, y >= 0,
7161    /// (y - x) complements y`. The complementarity row is `5 1 2` — flag 1
7162    /// (y has a finite lower bound), variable 2 (1-based).
7163    fn ralph1_nl(row_line: &str, y_bound: &str) -> String {
7164        [
7165            "g3 1 1 0",
7166            " 2 1 1 0 0", // vars, constraints, objectives, ranges, eqns
7167            " 0 0 1 0",   // nonlinear cons, objs; ccons: linear, nonlinear
7168            " 0 0",       // network constraints
7169            " 0 0 0",     // nonlinear vars in constraints, objectives, both
7170            " 0 0 0 1",   // linear network vars; functions; arith, flags
7171            " 0 0 0 0 0", // discrete variables
7172            " 2 2",       // Jacobian, gradient nonzeros
7173            " 0 0",       // max name lengths
7174            " 0 0 0 0 0", // common expressions
7175            "C0",
7176            "n0",
7177            "O0 0",
7178            "n0",
7179            "r",
7180            row_line,
7181            "b",
7182            "2 0",
7183            y_bound,
7184            "k1",
7185            "1",
7186            "J0 2",
7187            "0 -1",
7188            "1 1",
7189            "G0 2",
7190            "0 2",
7191            "1 -1",
7192        ]
7193        .join("\n")
7194            + "\n"
7195    }
7196
7197    #[test]
7198    fn complementarity_rows_are_read_not_refused() {
7199        let p = parse_nl_string(ralph1_nl("5 1 2", "2 0"), false).unwrap();
7200        assert_eq!(
7201            p.complementarity,
7202            vec![NlComplementarity {
7203                row: 0,
7204                var: 1,
7205                flags: 1
7206            }]
7207        );
7208        // Flag 1 (bounded below): the body must be >= 0.
7209        assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7210        assert_eq!((p.x_l[1], p.x_u[1]), (0.0, 1e19));
7211
7212        // Flag 2 (bounded above, y <= 0): the body must be <= 0.
7213        let p = parse_nl_string(ralph1_nl("5 2 2", "1 0"), false).unwrap();
7214        assert_eq!(p.complementarity[0].flags, 2);
7215        assert_eq!((p.g_l[0], p.g_u[0]), (-1e19, 0.0));
7216
7217        // Flag 0 (free variable): the body is an equation, and no pair.
7218        let p = parse_nl_string(ralph1_nl("5 0 2", "3"), false).unwrap();
7219        assert!(p.complementarity.is_empty());
7220        assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 0.0));
7221
7222        // Flag 3 (mixed) is recorded; the lowering is what refuses it.
7223        let p = parse_nl_string(ralph1_nl("5 3 2", "0 0 1"), false).unwrap();
7224        assert_eq!(p.complementarity[0].flags, 3);
7225
7226        // Malformed lines are errors, not silently dropped pairs.
7227        assert!(parse_nl_string(ralph1_nl("5 1", "2 0"), false).is_err());
7228        assert!(parse_nl_string(ralph1_nl("5 1 0", "2 0"), false).is_err());
7229        assert!(parse_nl_string(ralph1_nl("5 7 2", "2 0"), false).is_err());
7230    }
7231
7232    #[test]
7233    fn a_model_without_complementarity_reads_exactly_as_before() {
7234        // The same model with an ordinary `r` line: no pairs, the row's
7235        // bounds as written.
7236        let p = parse_nl_string(ralph1_nl("2 0", "2 0"), false).unwrap();
7237        assert!(p.complementarity.is_empty());
7238        assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7239    }
7240
7241    /// Run `f` on a thread with the stack the CLI and `pounce-py` provide,
7242    /// declared to the reader the way they declare it.
7243    fn on_big_stack(f: impl FnOnce() + Send + 'static) {
7244        std::thread::Builder::new()
7245            .stack_size(1 << 30)
7246            .spawn(move || {
7247                set_parse_stack_budget(Some(1 << 30));
7248                f();
7249                set_parse_stack_budget(None);
7250            })
7251            .unwrap()
7252            .join()
7253            .unwrap();
7254    }
7255
7256    /// A model whose objective is the last of `len` defined variables, each
7257    /// referencing the previous: `V_0 = x0^2`, `V_k = V_{k-1} + x0`, so the
7258    /// objective is `x0^2 + (len - 1) x0`. Every `V` segment is one level
7259    /// deep to the parser; the chain is `len` deep to anything that walks
7260    /// through it.
7261    fn v_chain_nl(len: usize) -> String {
7262        let mut l = vec![
7263            "g3 1 1 0".to_string(),
7264            " 1 1 1 0 1".into(),
7265            " 0 1".into(),
7266            " 0 0".into(),
7267            " 0 1 0".into(),
7268            " 0 0 0 1 0".into(),
7269            format!(" 0 0 {len} 0 0"),
7270            " 1 1".into(),
7271            " 0 0".into(),
7272            " 0 0 0 0 0".into(),
7273        ];
7274        l.extend(["V1 0 0".into(), "o5".into(), "v0".into(), "n2".into()]);
7275        for k in 1..len {
7276            l.extend([
7277                format!("V{} 0 0", k + 1),
7278                "o0".into(),
7279                format!("v{k}"),
7280                "v0".into(),
7281            ]);
7282        }
7283        l.extend(["C0".into(), "n0".into()]);
7284        l.extend(["O0 0".into(), format!("v{len}")]);
7285        l.extend(["r".into(), "4 0.5".into(), "b".into(), "0 -1.0 1.0".into()]);
7286        l.extend(["k0".into(), "J0 1".into(), "0 1.0".into()]);
7287        l.extend(["G0 1".into(), "0 0.0".into()]);
7288        l.join("\n") + "\n"
7289    }
7290
7291    /// gh#986 review item 9: a chain of defined variables is counted by the
7292    /// depth guard. Each `V` segment is shallow to the parser, so the first
7293    /// pass let a chain of any length through, and every pass that walks
7294    /// through a `Cse` (`collect_vars`, the tape builder, drop glue) then
7295    /// recursed once per link -- an abort on an ordinary stack. On a thread
7296    /// that declared no stack (the default budget) a chain far past the guard
7297    /// is now a clean `Err`.
7298    #[test]
7299    fn gh986_a_long_defined_variable_chain_is_refused_not_overflowed() {
7300        let limit = parse_depth_limit() as usize;
7301        let err = parse_nl_text(&v_chain_nl(limit + 50))
7302            .err()
7303            .expect("a V chain past the guard must be an Err");
7304        assert!(err.contains("defined") && err.contains("levels"), "{err}");
7305        // A short chain on the same thread is fine, and evaluates.
7306        let prob = parse_nl_text(&v_chain_nl(limit / 2)).expect("inside the guard");
7307        let x = [0.5];
7308        let want = 0.25 + (limit / 2 - 1) as f64 * 0.5;
7309        let got = eval_expr(&prob.obj_expr(), &x);
7310        assert!((got - want).abs() < 1e-9, "{got} vs {want}");
7311    }
7312
7313    /// gh#986 review item 9: the depth of a `V` body is memoized, so a long
7314    /// chain inside the guard costs time linear in the chain (the first
7315    /// pass's `expr_tree_depth` re-walked the whole chain on every segment).
7316    #[test]
7317    fn gh986_a_long_defined_variable_chain_inside_the_guard_parses() {
7318        on_big_stack(|| {
7319            // Two levels per link (the `o0` and the `Cse` reference), just
7320            // inside this thread's limit.
7321            let len = (parse_depth_limit() as usize - 100) / 2;
7322            let t = std::time::Instant::now();
7323            let prob = parse_nl_text(&v_chain_nl(len)).expect("inside the guard on a big stack");
7324            let got = eval_expr(&prob.obj_expr(), &[0.5]);
7325            let want = 0.25 + (len - 1) as f64 * 0.5;
7326            assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
7327            // Linear. With only `expr_tree_depth` memoized, the parse-time
7328            // quadratic passes still re-walked the chain per segment and this
7329            // ran past 60 s (measured, test profile); seeded, ~50 ms.
7330            let took = t.elapsed();
7331            eprintln!("v chain {len}: parsed and evaluated in {took:?}");
7332            assert!(took.as_secs() < 30, "{took:?}");
7333        });
7334    }
7335
7336    /// gh#986 review item 9: a thread that declares no stack is assumed to
7337    /// have Rust's default thread stack (wasm: 1 MiB), not the CLI's 1 GiB.
7338    #[test]
7339    fn gh986_an_undeclared_stack_gets_the_default_assumption() {
7340        std::thread::spawn(|| {
7341            set_parse_stack_budget(None);
7342            assert_eq!(
7343                parse_depth_limit() as usize,
7344                DEFAULT_ASSUMED_STACK / PARSE_LEVEL_BYTES
7345            );
7346            assert!((parse_depth_limit() as usize) < MAX_PARSE_DEPTH as usize);
7347        })
7348        .join()
7349        .unwrap();
7350    }
7351
7352    /// gh#986: a left-deep `o0` chain of any length is read as one n-ary
7353    /// `Sum` — one level deep — so the issue's n = 20 000 (39 999 nested
7354    /// `o0`) parses, and so does 100k, with the same value as the nested
7355    /// binary tree would have had.
7356    #[test]
7357    fn gh986_long_o0_chain_is_read_as_a_flat_sum() {
7358        on_big_stack(|| {
7359            for n in [5_000usize, 20_000, 100_000] {
7360                let prob = parse_nl_text(&deep_sum_nl(n, 0)).expect("a long o0 sum must parse");
7361                let x: Vec<f64> = (0..n).map(|i| (i % 7) as f64 * 0.1).collect();
7362                let want: f64 = x.iter().map(|v| v.exp() + v * v).sum();
7363                let tree = prob.obj_expr();
7364                assert!(expr_tree_depth(&tree, &Default::default()) < 16, "n = {n}");
7365                let got = eval_expr(&tree, &x);
7366                assert!((got - want).abs() <= 1e-9 * want.abs(), "{got} vs {want}");
7367            }
7368        });
7369    }
7370
7371    /// gh#986: the guard follows the stack the thread declares, so a thread
7372    /// with an ordinary 8 MiB stack refuses deep nesting cleanly — in an
7373    /// unoptimised build (the worst frame size) exactly as in an optimised
7374    /// one — instead of overflowing it. This is the reliability the
7375    /// `parse_nl_text` SIGBUS/SIGSEGV reports lacked.
7376    #[test]
7377    fn gh986_guard_follows_the_declared_stack() {
7378        let stack = 8 << 20;
7379        std::thread::Builder::new()
7380            .stack_size(stack)
7381            .spawn(move || {
7382                set_parse_stack_budget(Some(stack));
7383                assert_eq!(parse_depth_limit() as usize, stack / PARSE_LEVEL_BYTES);
7384                let msg = parse_nl_text(&deep_sum_nl(4, 20_000))
7385                    .err()
7386                    .expect("40 000 levels cannot fit 8 MiB");
7387                assert!(msg.contains("levels"), "{msg}");
7388                // and a shallow model on the same thread is fine
7389                assert!(parse_nl_text(&deep_sum_nl(4, 50)).is_ok());
7390                set_parse_stack_budget(None);
7391            })
7392            .unwrap()
7393            .join()
7394            .unwrap();
7395    }
7396
7397    /// gh#986: nesting that really is deep (negations) past the guard is
7398    /// refused with an `Err`, at any depth, instead of overflowing the stack;
7399    /// nesting just inside the guard still parses. Both on the stack the
7400    /// callers provide, in debug as in release — the guard's headroom is
7401    /// what this pins.
7402    #[test]
7403    fn gh986_deep_nesting_is_a_clean_error_past_the_guard_and_parses_inside_it() {
7404        on_big_stack(|| {
7405            let deep = parse_nl_text(&deep_sum_nl(4, 30_000));
7406            let msg = deep.err().expect("past the guard must be an Err");
7407            assert!(msg.contains("levels"), "{msg}");
7408            // Just inside the limit this thread's declared stack gives
7409            // (`MAX_PARSE_DEPTH` in an optimised build, 32 768 in an
7410            // unoptimised one, whose worst-case frame is 32 KiB).
7411            let wraps = (parse_depth_limit() as usize - 100) / 2;
7412            assert!(parse_nl_text(&deep_sum_nl(4, wraps)).is_ok());
7413        });
7414    }
7415
7416    use super::*;
7417
7418    /// Compile-time guarantee for the batched-solve path (pounce#126):
7419    /// a parsed problem and the TNLP built from it must be movable to a
7420    /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
7421    /// `!Send` state) into the `Expr` DAG / tape pipeline.
7422    #[test]
7423    fn nl_problem_and_tnlp_are_send() {
7424        fn assert_send<T: Send>() {}
7425        assert_send::<NlProblem>();
7426        assert_send::<NlTnlp>();
7427        assert_send::<Expr>();
7428    }
7429
7430    /// `variant()` patches starting point / bounds on a clone and
7431    /// validates override lengths; the base instance is untouched.
7432    #[test]
7433    fn variant_overrides_bounds_and_x0() {
7434        let p = parse_nl_text(SIMPLE).expect("parse");
7435        let base = NlTnlp::new(p);
7436        let var = base
7437            .variant(&NlVariation {
7438                x0: Some(vec![3.0, 4.0]),
7439                x_l: Some(vec![-1.0, -2.0]),
7440                x_u: Some(vec![5.0, 6.0]),
7441                ..Default::default()
7442            })
7443            .expect("variant");
7444        let mut var = var;
7445        let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
7446        let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
7447        assert!(var.get_bounds_info(BoundsInfo {
7448            x_l: &mut x_l,
7449            x_u: &mut x_u,
7450            g_l: &mut g_l,
7451            g_u: &mut g_u,
7452        }));
7453        assert_eq!(x_l, [-1.0, -2.0]);
7454        assert_eq!(x_u, [5.0, 6.0]);
7455        let mut x = [0.0; 2];
7456        let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
7457        assert!(var.get_starting_point(StartingPoint {
7458            init_x: true,
7459            x: &mut x,
7460            init_z: false,
7461            z_l: &mut zl,
7462            z_u: &mut zu,
7463            init_lambda: false,
7464            lambda: &mut lam,
7465        }));
7466        assert_eq!(x, [3.0, 4.0]);
7467        // Base keeps its parsed (free) bounds.
7468        assert!(base.problem().x_l[0] < -1.0e18);
7469        // Length mismatch is an error, not a panic.
7470        assert!(
7471            base.variant(&NlVariation {
7472                x0: Some(vec![1.0]),
7473                ..Default::default()
7474            })
7475            .is_err()
7476        );
7477    }
7478
7479    /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
7480    /// Header values:
7481    ///   line 2: n=2 m=0 num_obj=1 0 0
7482    ///   line 3: 0 1   (1 nonlinear objective)
7483    ///   line 4: 0 0
7484    ///   line 5: 0 2 0 (nonlinear vars in obj=2)
7485    ///   line 6: 0 0 0 1
7486    ///   line 7: 0 0 0 0 0
7487    ///   line 8: 0 0   (no Jacobian nonzeros, no linear obj)
7488    ///   line 9: 0 0
7489    ///   line 10: 0 0 0 0 0
7490    /// Then `O0 0` followed by an expression tree:
7491    /// `(x0 - 1)^2 + (x1 - 2)^2` =
7492    ///   o0
7493    ///     o5 (o1 v0 n1) n2
7494    ///     o5 (o1 v1 n2) n2
7495    /// Then `b` segment: free for both.
7496    /// gh#987: Gay's ordering places nonlinear integers at the END of each
7497    /// nonlinear block and linear ones at the end of the model.
7498    #[test]
7499    fn discrete_census_locates_nonlinear_and_linear_integer_columns() {
7500        // n = 10: nlvb = 2 (1 int), con-only 3 (1 int), obj-only 1 (1 int),
7501        // then linear, ending in nbv = 1, niv = 1.
7502        let d = DiscreteCensus {
7503            binary: 1,
7504            integer: 1,
7505            nl_both: 1,
7506            nl_cons: 1,
7507            nl_objs: 1,
7508        };
7509        let c = NlCounts {
7510            nl_cons: 1,
7511            nl_objs: 1,
7512            nl_vars_cons: 5,
7513            nl_vars_objs: 6,
7514            nl_vars_both: 2,
7515        };
7516        assert_eq!(d.total(), 5);
7517        assert_eq!(d.integer_columns(10, Some(c)), Some(vec![1, 4, 5, 8, 9]));
7518        // Without the line-5 census the nonlinear columns are unknowable.
7519        assert_eq!(d.integer_columns(10, None), None);
7520        // Linear-only integers need no census.
7521        let lin = DiscreteCensus {
7522            binary: 2,
7523            ..Default::default()
7524        };
7525        assert_eq!(lin.integer_columns(4, None), Some(vec![2, 3]));
7526        // Inconsistent counts: more nonlinear-in-both integers than columns.
7527        let bad = DiscreteCensus {
7528            nl_both: 3,
7529            ..Default::default()
7530        };
7531        assert_eq!(bad.integer_columns(10, Some(c)), None);
7532    }
7533
7534    const SIMPLE: &str = "g3 0 1 0
75352 0 1 0 0
75360 1
75370 0
75380 2 0
75390 0 0 1
75400 0 0 0 0
75410 0
75420 0
75430 0 0 0 0
7544O0 0
7545o0
7546o5
7547o1
7548v0
7549n1
7550n2
7551o5
7552o1
7553v1
7554n2
7555n2
7556b
75573
75583
7559";
7560
7561    #[test]
7562    fn parses_simple_quadratic() {
7563        let p = parse_nl_text(SIMPLE).expect("parse");
7564        assert_eq!(p.n, 2);
7565        assert_eq!(p.m, 0);
7566        assert_eq!(p.num_obj, 1);
7567        // f(0,0) = 1 + 4 = 5
7568        let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
7569        assert!((f - 5.0).abs() < 1e-12);
7570        // f(1,2) = 0
7571        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7572        assert!(f.abs() < 1e-12);
7573    }
7574
7575    #[test]
7576    fn gradient_matches_analytic() {
7577        let p = parse_nl_text(SIMPLE).expect("parse");
7578        let x = [0.5, 1.0];
7579        let mut g = [0.0_f64; 2];
7580        grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
7581        // d/dx0 = 2*(x0-1) = -1.0
7582        // d/dx1 = 2*(x1-2) = -2.0
7583        assert!((g[0] - (-1.0)).abs() < 1e-12);
7584        assert!((g[1] - (-2.0)).abs() < 1e-12);
7585    }
7586
7587    /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
7588    /// with global semantics so the presolve auxiliary-elimination
7589    /// safeguard actually engages. Pre-fix the default trait stub returned
7590    /// `false` and left the slice untouched, so a variable that is
7591    /// nonlinear in the objective but zero-gradient at the probe point
7592    /// could be wrongly eliminated.
7593    ///
7594    /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
7595    /// of the objective (NonLinear); x1 appears only in the linear part
7596    /// (Linear).
7597    #[test]
7598    fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
7599        // (x0 - 1)^2
7600        let obj_nl = Expr::Binary(
7601            BinOp::Pow,
7602            Box::new(Expr::Binary(
7603                BinOp::Sub,
7604                Box::new(Expr::Var(0)),
7605                Box::new(Expr::Const(1.0)),
7606            )),
7607            Box::new(Expr::Const(2.0)),
7608        );
7609        let prob = NlProblem {
7610            src: None,
7611            cse_bodies: Vec::new(),
7612            complementarity: Vec::new(),
7613            n: 2,
7614            m: 0,
7615            num_obj: 1,
7616            minimize: true,
7617            obj_nonlinear: NlBody::Tree(obj_nl),
7618            obj_linear: vec![(1, 3.0)],
7619            obj_constant: 0.0,
7620            con_nonlinear: vec![],
7621            con_linear: vec![],
7622            x_l: vec![f64::NEG_INFINITY; 2],
7623            x_u: vec![f64::INFINITY; 2],
7624            g_l: vec![],
7625            g_u: vec![],
7626            x0: vec![0.0; 2],
7627            lambda0: vec![],
7628            suffixes: NlSuffixes::default(),
7629            imported_funcs: vec![],
7630            ampl_options: vec![],
7631            nl_counts: None,
7632            n_discrete: None,
7633            var_names: vec![],
7634            con_names: vec![],
7635        };
7636        let mut tnlp = NlTnlp::new(prob);
7637        let mut types = vec![Linearity::Linear; 2];
7638        let ok = tnlp.get_variables_linearity(&mut types);
7639        // Pre-fix: default stub returns false (slice untouched).
7640        assert!(
7641            ok,
7642            "get_variables_linearity must report it filled the slice"
7643        );
7644        assert!(
7645            matches!(types[0], Linearity::NonLinear),
7646            "x0 is nonlinear in the objective"
7647        );
7648        assert!(
7649            matches!(types[1], Linearity::Linear),
7650            "x1 appears only in the linear part"
7651        );
7652    }
7653
7654    /// Objective-scoped linearity must NOT inherit constraint
7655    /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
7656    /// (constraint tape) but linear w.r.t. the objective, so the presolve
7657    /// H11 guard must not treat it as objective-coupled — that was the CI
7658    /// regression where every gas-network variable (nonlinear in the flow
7659    /// equations, absent from the linear objective) blocked Phase-0
7660    /// elimination.
7661    #[test]
7662    fn objective_variables_linearity_ignores_constraint_nonlinearity() {
7663        // x0^2
7664        let con_nl = Expr::Binary(
7665            BinOp::Pow,
7666            Box::new(Expr::Var(0)),
7667            Box::new(Expr::Const(2.0)),
7668        );
7669        let prob = NlProblem {
7670            src: None,
7671            cse_bodies: Vec::new(),
7672            complementarity: Vec::new(),
7673            n: 2,
7674            m: 1,
7675            num_obj: 1,
7676            minimize: true,
7677            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
7678            obj_linear: vec![(1, 3.0)],
7679            obj_constant: 0.0,
7680            con_nonlinear: vec![NlBody::Tree(con_nl)],
7681            con_linear: vec![vec![]],
7682            x_l: vec![f64::NEG_INFINITY; 2],
7683            x_u: vec![f64::INFINITY; 2],
7684            g_l: vec![4.0],
7685            g_u: vec![4.0],
7686            x0: vec![0.0; 2],
7687            lambda0: vec![0.0],
7688            suffixes: NlSuffixes::default(),
7689            imported_funcs: vec![],
7690            ampl_options: vec![],
7691            nl_counts: None,
7692            n_discrete: None,
7693            var_names: vec![],
7694            con_names: vec![],
7695        };
7696        let mut tnlp = NlTnlp::new(prob);
7697
7698        let mut global = vec![Linearity::Linear; 2];
7699        assert!(tnlp.get_variables_linearity(&mut global));
7700        assert!(
7701            matches!(global[0], Linearity::NonLinear),
7702            "global tags see x0's constraint nonlinearity"
7703        );
7704
7705        let mut obj = vec![Linearity::NonLinear; 2];
7706        assert!(tnlp.get_objective_variables_linearity(&mut obj));
7707        assert!(
7708            matches!(obj[0], Linearity::Linear),
7709            "x0 is linear w.r.t. the objective despite the nonlinear constraint"
7710        );
7711        assert!(
7712            matches!(obj[1], Linearity::Linear),
7713            "x1 is linear everywhere"
7714        );
7715    }
7716
7717    /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
7718    /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
7719    /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
7720    /// constraints, and both variables nonlinear in the objective only.
7721    #[test]
7722    fn header_census_is_parsed() {
7723        let p = parse_nl_text(SIMPLE).expect("parse");
7724        let c = p.nl_counts.expect("SIMPLE has a well-formed header");
7725        assert_eq!(c.nl_cons, 0);
7726        assert_eq!(c.nl_objs, 1);
7727        assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
7728        assert_eq!(c.nonlinear_vars(), 2);
7729    }
7730
7731    /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
7732    /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
7733    /// as the overlapping one: disjoint sets add, and `max` would be wrong
7734    /// for them.
7735    #[test]
7736    fn nonlinear_var_total_uses_inclusion_exclusion() {
7737        let c = |vc, vo, vb| NlCounts {
7738            nl_cons: 0,
7739            nl_objs: 0,
7740            nl_vars_cons: vc,
7741            nl_vars_objs: vo,
7742            nl_vars_both: vb,
7743        };
7744        // Fully shared: the same 5 variables in both.
7745        assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
7746        // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
7747        // not the `max(nlvc, nlvo) = 1` a naive reading gives.
7748        assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
7749        // Partial overlap: 4 + 3 - 2.
7750        assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
7751        // Nonsense header: saturates instead of underflowing.
7752        assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
7753    }
7754
7755    /// A header that does not carry the documented fields records no
7756    /// census at all rather than a guess of zero — "no nonlinear
7757    /// variables" is a claim, and a truncated header has not made it.
7758    #[test]
7759    fn short_header_line_records_no_census() {
7760        // Line 5 with two fields instead of `nlvc nlvo nlvb`.
7761        let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
7762        assert_ne!(txt, SIMPLE, "the substitution must have applied");
7763        let p = parse_nl_text(&txt).expect("parse");
7764        assert!(p.nl_counts.is_none());
7765    }
7766
7767    /// `get_number_of_nonlinear_variables` used to be the trait default,
7768    /// `-1` ("assume everything is nonlinear"). It now answers from the
7769    /// trees, and `get_list_of_nonlinear_variables` agrees with it.
7770    ///
7771    /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
7772    #[test]
7773    fn nonlinear_variable_list_excludes_linear_columns() {
7774        let obj_nl = Expr::Binary(
7775            BinOp::Pow,
7776            Box::new(Expr::Binary(
7777                BinOp::Sub,
7778                Box::new(Expr::Var(0)),
7779                Box::new(Expr::Const(1.0)),
7780            )),
7781            Box::new(Expr::Const(2.0)),
7782        );
7783        let parts = NlProblemParts {
7784            minimize: true,
7785            objective: obj_nl,
7786            obj_constant: 0.0,
7787            constraints: vec![],
7788            x_l: vec![-1e19; 2],
7789            x_u: vec![1e19; 2],
7790            x0: vec![0.0; 2],
7791            g_l: vec![],
7792            g_u: vec![],
7793            var_names: vec![],
7794            con_names: vec![],
7795        };
7796        let prob = NlProblem::from_expressions(parts).expect("build");
7797        assert!(
7798            prob.nl_counts.is_none(),
7799            "a model built in memory has no header to read"
7800        );
7801        let mut tnlp = NlTnlp::new(prob);
7802        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
7803        let mut list = [-1 as Index; 2];
7804        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7805        assert_eq!(list[0], 0);
7806    }
7807
7808    /// When the header says every variable is nonlinear the walk is
7809    /// skipped, and the answer is the same one the walk would give for
7810    /// `SIMPLE` (both variables appear in the objective's nonlinear part).
7811    #[test]
7812    fn all_nonlinear_header_short_circuits_to_n() {
7813        let p = parse_nl_text(SIMPLE).expect("parse");
7814        assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
7815        let mut tnlp = NlTnlp::new(p);
7816        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
7817        let mut list = [-1 as Index; 2];
7818        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7819        assert_eq!(list, [0, 1]);
7820    }
7821
7822    /// The list must not be written when the caller's slice is too small —
7823    /// the contract's `false` return, not a panic.
7824    #[test]
7825    fn nonlinear_variable_list_declines_a_short_slice() {
7826        let p = parse_nl_text(SIMPLE).expect("parse");
7827        let mut tnlp = NlTnlp::new(p);
7828        let mut list = [-1 as Index; 1];
7829        assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
7830        assert_eq!(list, [-1]);
7831    }
7832
7833    /// `min x0^2 + x1^2  s.t.  x0 + x1 = 1`.
7834    /// One equality constraint with a purely linear Jacobian — exercises
7835    /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
7836    /// kind 4).
7837    ///
7838    /// Header layout:
7839    ///   line 1: g3 0 1 0
7840    ///   line 2: 2 1 1 0 0   (n=2, m=1, num_obj=1)
7841    ///   line 3: 0 1         (1 nonlinear obj, 0 nonlinear cons)
7842    ///   line 4: 0 0
7843    ///   line 5: 0 2 0       (nonlinear vars in obj=2)
7844    ///   line 6: 0 0 0 1
7845    ///   line 7: 0 0 0 0 0
7846    ///   line 8: 2 0         (Jacobian nnz=2, no linear obj)
7847    ///   line 9: 0 0
7848    ///   line 10: 0 0 0 0 0
7849    /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
7850    /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
7851    const EQ_LIN: &str = "g3 0 1 0
78522 1 1 0 0
78530 1
78540 0
78550 2 0
78560 0 0 1
78570 0 0 0 0
78582 0
78590 0
78600 0 0 0 0
7861C0
7862n0
7863O0 0
7864o0
7865o5
7866v0
7867n2
7868o5
7869v1
7870n2
7871r
78724 1
7873b
78743
78753
7876k1
78772
7878J0 2
78790 1
78801 1
7881";
7882
7883    #[test]
7884    fn parses_constrained_problem() {
7885        let p = parse_nl_text(EQ_LIN).expect("parse");
7886        assert_eq!(p.n, 2);
7887        assert_eq!(p.m, 1);
7888        // r-segment kind 4 (equality with rhs=1).
7889        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7890        assert!((p.g_u[0] - 1.0).abs() < 1e-12);
7891        // J-row 0: x0 (coef 1), x1 (coef 1).
7892        assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
7893    }
7894
7895    #[test]
7896    fn malformed_j_variable_index_is_parse_error_not_panic() {
7897        // Code review L32: a J-segment entry's variable (column) index was
7898        // pushed into con_linear unchecked, so an out-of-range index (here 5
7899        // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
7900        // constraint evaluation. It must instead surface as a clean parse
7901        // error, consistent with the existing `J<row> out of range` check.
7902        let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
7903        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7904        let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
7905        assert!(err.contains("out of range"), "unexpected error: {err}");
7906    }
7907
7908    #[test]
7909    fn out_of_range_x_segment_index_is_parse_error() {
7910        // Same strictness for the initial-primal `x` segment: an index past
7911        // `n` used to be silently dropped; now it is a parse error, so the
7912        // four index-bearing segments (J/G/x/d) behave consistently.
7913        let bad = format!("{EQ_LIN}x1\n5 0.5\n");
7914        let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
7915        assert!(err.contains("out of range"), "unexpected error: {err}");
7916    }
7917
7918    // ---------------------------------------------------------------
7919    // gh #492 — a constant `C<i>` body folds into the row bounds.
7920    //
7921    // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
7922    // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
7923    // constant-body rows to fold.
7924    // ---------------------------------------------------------------
7925
7926    /// Linearity is a *linearity* test, not "did a `C` segment touch this
7927    /// row". A row whose body is the bare constant `3` is affine.
7928    #[test]
7929    fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
7930        // `x0 + x1 + 3 = 1`  ⇔  `x0 + x1 = -2`.
7931        let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7932        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7933        let p = parse_nl_text(&nl).expect("parse");
7934
7935        assert!(
7936            p.con_nonlinear[0].is_trivially_zero(),
7937            "the constant body must be replaced by the identity zero, got {:?}",
7938            p.con_nonlinear[0]
7939        );
7940        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7941        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7942
7943        let mut lin = [Linearity::NonLinear];
7944        let mut t = NlTnlp::new(p);
7945        assert!(t.get_constraints_linearity(&mut lin));
7946        assert_eq!(lin[0], Linearity::Linear);
7947    }
7948
7949    /// The shift must be exact, not merely "linear now": the folded model
7950    /// and the hand-folded one must be the same problem, row body for row
7951    /// body and bound for bound. That is what keeps feasibility, the
7952    /// active set, and the duals unchanged.
7953    #[test]
7954    fn folding_a_row_constant_gives_the_hand_folded_problem() {
7955        // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
7956        let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7957        let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
7958        assert_ne!(offset, EQ_LIN);
7959        assert_ne!(folded, EQ_LIN);
7960
7961        let a = parse_nl_text(&offset).expect("parse offset form");
7962        let b = parse_nl_text(&folded).expect("parse folded form");
7963        assert_eq!(a.g_l, b.g_l);
7964        assert_eq!(a.g_u, b.g_u);
7965        assert_eq!(a.con_linear, b.con_linear);
7966
7967        // And the row *values* agree pointwise, which is the property the
7968        // duals ride on: `g(x) - g_l` is the same residual either way.
7969        let mut ga = [0.0];
7970        let mut gb = [0.0];
7971        let x = [0.75, -1.25];
7972        assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
7973        assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
7974        assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
7975    }
7976
7977    /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
7978    /// constant as `n3`, and AMPL emits such trees when a expression
7979    /// collapses without being re-simplified.
7980    #[test]
7981    fn a_row_body_that_evaluates_to_a_constant_folds_too() {
7982        // `C0` = `1 + 2`.
7983        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
7984        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7985        let p = parse_nl_text(&nl).expect("parse");
7986        assert!(p.con_nonlinear[0].is_trivially_zero());
7987        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7988        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7989    }
7990
7991    /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
7992    /// "absent", not "a very large number". Shifting one turns it into a
7993    /// real bound and invents a constraint that is not in the model.
7994    ///
7995    /// The constants here are deliberately huge. An everyday `3` is
7996    /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
7997    /// presence guard would go unnoticed at ordinary magnitudes and then
7998    /// bite on a model that scales its rows. The guard is what makes the
7999    /// sentinel untouchable at *any* magnitude.
8000    #[test]
8001    fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
8002        // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
8003        // bound. Shifting the lower sentinel would leave `-9e18`, a real
8004        // bound, so the row would gain a floor the model never stated.
8005        let nl = EQ_LIN
8006            .replace("C0\nn0\n", "C0\nn-1e18\n")
8007            .replace("r\n4 1\n", "r\n1 1\n");
8008        let p = parse_nl_text(&nl).expect("parse");
8009        assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
8010        assert!(
8011            !lower_bound_present(p.g_l[0]),
8012            "the absent-lower sentinel became a real bound: {}",
8013            p.g_l[0]
8014        );
8015
8016        // The mirror case: a positive constant on a lower-bounded row is
8017        // the one that would pull the *upper* sentinel below 1e19.
8018        let nl = EQ_LIN
8019            .replace("C0\nn0\n", "C0\nn1e18\n")
8020            .replace("r\n4 1\n", "r\n2 1\n");
8021        let p = parse_nl_text(&nl).expect("parse");
8022        assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
8023        assert!(
8024            !upper_bound_present(p.g_u[0]),
8025            "the absent-upper sentinel became a real bound: {}",
8026            p.g_u[0]
8027        );
8028    }
8029
8030    /// A row body that mentions a variable is not a constant, however
8031    /// simple it looks. Folding it would delete the term.
8032    #[test]
8033    fn a_row_body_with_a_variable_is_not_folded() {
8034        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
8035        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8036        let p = parse_nl_text(&nl).expect("parse");
8037        assert!(
8038            !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
8039            "a row in x0 was folded away: {:?}",
8040            p.con_nonlinear[0]
8041        );
8042        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8043        assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
8044    }
8045
8046    /// A variable-free body whose value is not finite is left in place. It
8047    /// makes the row infeasible (or ill-posed) and that is the solver's
8048    /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
8049    /// corrupt the bound pair and take every downstream presence test with
8050    /// it.
8051    #[test]
8052    fn a_non_finite_constant_row_body_is_not_folded() {
8053        // `C0` = `log(-1)` = NaN.
8054        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
8055        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8056        let p = parse_nl_text(&nl).expect("parse");
8057        assert!(
8058            !p.con_nonlinear[0].is_trivially_zero(),
8059            "a NaN body was folded into the bounds"
8060        );
8061        assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
8062        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
8063    }
8064
8065    /// An imported-function call is not a parse-time constant even with
8066    /// constant arguments — it is resolved to a shared library much later
8067    /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
8068    /// rather than guess. The fold must decline before it evaluates.
8069    #[test]
8070    fn a_constant_argument_funcall_row_body_is_not_folded() {
8071        // Declare one imported function and call it with a literal.
8072        let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
8073        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8074        let p = parse_nl_text(&nl).expect("parse");
8075        assert!(
8076            matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
8077            "expected the funcall to survive the fold, got {:?}",
8078            p.con_nonlinear[0]
8079        );
8080        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8081    }
8082
8083    #[test]
8084    fn k_segment_nonstandard_count_is_parse_error_at_source() {
8085        // Code review L35: the `k` (Jacobian column-count) segment header
8086        // declares how many count lines follow — `k<count>` — and the
8087        // standard value is n-1. The parser used to *assume* n-1 and ignore
8088        // the header, so a file declaring a different count read the wrong
8089        // number of data lines, desynced the segment stream, and failed far
8090        // downstream with a confusing error (or silently mis-parsed). With
8091        // the declared count now read and validated, a nonstandard count is
8092        // a clear parse error at its source. Here EQ_LIN has n=2 (expected
8093        // count 1); rewrite its `k1` + one count line to `k0`.
8094        let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
8095        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
8096        let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
8097        assert!(
8098            err.contains("k-segment declares"),
8099            "expected a clear k-segment count error, got: {err}"
8100        );
8101    }
8102
8103    #[test]
8104    fn get_starting_point_returns_nl_initial_duals() {
8105        // Code review 2026-06 item M19: the `.nl` `d` segment supplies
8106        // initial constraint multipliers. They are parsed into `lambda0`,
8107        // but `get_starting_point` previously ignored them — so a
8108        // `warm_start_init_point yes` solve silently began from zero duals.
8109        // `get_starting_point` must hand the parsed duals back when the
8110        // engine requests them (`init_lambda`), and leave the buffer
8111        // untouched when it does not.
8112        let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
8113        let p = parse_nl_text(&nl).expect("parse");
8114        // gh#1033: a `d` entry is an AMPL dual, so it is stored negated.
8115        assert_eq!(p.lambda0, vec![-2.5], "the `d` segment fills lambda0");
8116
8117        let mut t = NlTnlp::new(p);
8118        let info = t.get_nlp_info().unwrap();
8119        let (n, m) = (info.n as usize, info.m as usize);
8120
8121        // Warm-start request: init_lambda = true → the parsed `.nl` duals
8122        // must be returned (pre-fix this stayed zero).
8123        let mut x = vec![0.0; n];
8124        let mut z_l = vec![0.0; n];
8125        let mut z_u = vec![0.0; n];
8126        let mut lambda = vec![0.0; m];
8127        assert!(t.get_starting_point(StartingPoint {
8128            init_x: true,
8129            x: &mut x,
8130            init_z: false,
8131            z_l: &mut z_l,
8132            z_u: &mut z_u,
8133            init_lambda: true,
8134            lambda: &mut lambda,
8135        }));
8136        assert_eq!(
8137            lambda,
8138            vec![-2.5],
8139            "a warm start must use the `.nl` initial duals, not zero"
8140        );
8141
8142        // No warm-start request: the multiplier buffer is left alone (the
8143        // engine owns its default), so honoring the flag does not clobber it.
8144        let mut lambda_untouched = vec![7.0; m];
8145        assert!(t.get_starting_point(StartingPoint {
8146            init_x: true,
8147            x: &mut x,
8148            init_z: false,
8149            z_l: &mut z_l,
8150            z_u: &mut z_u,
8151            init_lambda: false,
8152            lambda: &mut lambda_untouched,
8153        }));
8154        assert_eq!(
8155            lambda_untouched,
8156            vec![7.0],
8157            "without init_lambda the multiplier buffer must be untouched"
8158        );
8159    }
8160
8161    /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
8162    /// unconstrained, `n` variables. The trailing product puts a nonzero
8163    /// in row 0 of *every* Hessian column, which is the shape that used
8164    /// to force the greedy coloring to hand out one color per variable.
8165    fn dense_row_objective_nl(n: usize) -> String {
8166        let mut s = String::new();
8167        s.push_str("g3 1 1 0\n");
8168        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8169        s.push_str(" 0 1\n 0 0\n");
8170        s.push_str(&format!(" {n} {n} {n}\n"));
8171        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8172        s.push_str(&format!(" 0 {n}\n"));
8173        s.push_str(" 0 0\n 0 0 0 0 0\n");
8174        // objective
8175        s.push_str("O0 0\n");
8176        s.push_str(&format!("o54\n{}\n", n + 1));
8177        for j in 0..n {
8178            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8179        }
8180        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8181        for j in 0..n {
8182            s.push_str(&format!("v{j}\n"));
8183        }
8184        // start, bounds, gradient
8185        s.push_str(&format!("x{n}\n"));
8186        for j in 0..n {
8187            s.push_str(&format!("{j} 0.5\n"));
8188        }
8189        s.push_str("b\n");
8190        for _ in 0..n {
8191            s.push_str("3\n");
8192        }
8193        s.push_str(&format!("G0 {n}\n"));
8194        for j in 0..n {
8195            s.push_str(&format!("{j} 0.0\n"));
8196        }
8197        s
8198    }
8199
8200    /// Same shape as [`dense_row_objective_nl`], but the coupling term
8201    /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
8202    /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
8203    /// magnitude makes the dense column ill-scaled — the case where
8204    /// reading its entries out of its own pass costs real digits.
8205    fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
8206        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8207        let mut s = String::new();
8208        s.push_str("g3 1 1 0\n");
8209        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8210        s.push_str(" 0 1\n 0 0\n");
8211        s.push_str(&format!(" {n} {n} {n}\n"));
8212        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8213        s.push_str(&format!(" 0 {n}\n"));
8214        s.push_str(" 0 0\n 0 0 0 0 0\n");
8215        s.push_str("O0 0\n");
8216        s.push_str(&format!("o54\n{}\n", n + 1));
8217        for j in 0..n {
8218            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8219        }
8220        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8221        for j in 0..n {
8222            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8223        }
8224        s.push_str(&format!("x{n}\n"));
8225        for j in 0..n {
8226            s.push_str(&format!("{j} 0.5\n"));
8227        }
8228        s.push_str("b\n");
8229        for _ in 0..n {
8230            s.push_str("3\n");
8231        }
8232        s.push_str(&format!("G0 {n}\n"));
8233        for j in 0..n {
8234            s.push_str(&format!("{j} 0.0\n"));
8235        }
8236        s
8237    }
8238
8239    /// Locate a model in the benchmark corpus, or `None` if the corpus is
8240    /// not on this machine.
8241    ///
8242    /// The corpus is ~2 GB and deliberately outside the checkout (see
8243    /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
8244    /// no-op rather than fail. That is a real limitation — a check that
8245    /// silently does nothing is how the gap below went unnoticed in the
8246    /// first place — so anything using this must also be covered by a
8247    /// synthetic case that always runs.
8248    fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
8249        let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
8250        let p = std::path::PathBuf::from(root).join(rel);
8251        p.is_file().then_some(p)
8252    }
8253
8254    /// The corpus check the dense-column optimization never got.
8255    ///
8256    /// `cho_parmest` is the model whose certificate the optimization cost,
8257    /// and it could not have caught it: the model is 4.3 MB and lives in
8258    /// the benchmark data set, not in the repository, so validating
8259    /// against the in-repo `.nl` fixtures said nothing about it. This test
8260    /// closes that by checking the corpus directly wherever the corpus
8261    /// exists, which is every machine and CI job that runs the benchmarks.
8262    ///
8263    /// The assertion is the one that matters and the one that was never
8264    /// made: whatever the guard leaves peeled must decode to the same
8265    /// Hessian as peeling nothing. Against the pre-fix code this fails —
8266    /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
8267    #[test]
8268    fn cho_parmest_decodes_to_its_unpeeled_reference() {
8269        let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
8270            eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
8271            return;
8272        };
8273        let p = read_nl_file(&path).expect("read cho_parmest");
8274        let n = p.n;
8275        let mut t = NlTnlp::new(p);
8276        // The guard vetoes 7 of cho's 12 peeled columns, and putting those
8277        // seven dense rows back into the conflict graph costs the coloring
8278        // outright: it goes from 17 colors to 9010, and no column clears the
8279        // density threshold afterwards, so the model ends up fully unpeeled.
8280        // That is the price of the certificate on this model, and it is worth
8281        // knowing rather than assuming the other five survive.
8282        assert!(t.peeled_cols.is_empty());
8283
8284        let info = t.get_nlp_info().unwrap();
8285        let nnz = info.nnz_h_lag as usize;
8286        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8287        assert!(t.eval_h(
8288            None,
8289            true,
8290            1.0,
8291            None,
8292            true,
8293            SparsityRequest::Structure {
8294                irow: &mut irow,
8295                jcol: &mut jcol
8296            }
8297        ));
8298        // Evaluate away from `x0` with non-uniform multipliers. The damage
8299        // is point-dependent, and `x0` is close to where it is least
8300        // visible: only 5 entries move there, against 48,931 here. Note the
8301        // guard's own probe runs at `x0` — it fires anyway because it tests
8302        // a bound on what a pass *could* lose, not the damage it happens to
8303        // commit at one point. That is the property that makes it robust,
8304        // and this asymmetry is worth keeping in front of anyone who
8305        // retunes it.
8306        let x: Vec<f64> = t
8307            .prob
8308            .x0
8309            .iter()
8310            .enumerate()
8311            .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
8312            .collect();
8313        let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
8314
8315        let mut got = vec![0.0_f64; nnz];
8316        assert!(t.eval_h(
8317            Some(&x),
8318            true,
8319            1.0,
8320            Some(&lambda),
8321            true,
8322            SparsityRequest::Values { values: &mut got }
8323        ));
8324
8325        t.recolor(&vec![true; n]);
8326        assert!(t.peeled_cols.is_empty());
8327        let mut want = vec![0.0_f64; nnz];
8328        assert!(t.eval_h(
8329            Some(&x),
8330            true,
8331            1.0,
8332            Some(&lambda),
8333            true,
8334            SparsityRequest::Values { values: &mut want }
8335        ));
8336
8337        let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
8338        let mut worst = 0.0_f64;
8339        let mut at = 0usize;
8340        for k in 0..nnz {
8341            let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8342            if rel > worst {
8343                worst = rel;
8344                at = k;
8345            }
8346        }
8347        assert!(
8348            worst <= 1e-13,
8349            "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
8350             {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
8351             out of a pass it cannot be read out of.",
8352            irow[at],
8353            jcol[at],
8354            got[at],
8355            want[at]
8356        );
8357
8358        // The check above passes trivially on correct code, because the
8359        // guard leaves cho fully unpeeled and so compares a configuration
8360        // against itself. It only has teeth against a regression. So prove
8361        // the guard is doing necessary work rather than assuming it: put
8362        // the peels back the way the pre-fix reader had them and confirm
8363        // the Hessian really does come apart.
8364        t.recolor(&vec![false; n]);
8365        assert!(
8366            !t.peeled_cols.is_empty(),
8367            "restoring the unvetoed coloring must peel again"
8368        );
8369        let mut unguarded = vec![0.0_f64; nnz];
8370        assert!(t.eval_h(
8371            Some(&x),
8372            true,
8373            1.0,
8374            Some(&lambda),
8375            true,
8376            SparsityRequest::Values {
8377                values: &mut unguarded
8378            }
8379        ));
8380        let mut bad = 0usize;
8381        let mut worst_unguarded = 0.0_f64;
8382        for k in 0..nnz {
8383            let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8384            if rel > 1e-13 {
8385                bad += 1;
8386            }
8387            worst_unguarded = worst_unguarded.max(rel);
8388        }
8389        // Two different counts get quoted about this model and they measure
8390        // different things: 48,931 of the 96,000 entries differ from the
8391        // uncompressed reference *at all*, down to the last bit, while 88
8392        // exceed 1e-13 relative. The second is the one worth asserting on.
8393        assert!(
8394            bad >= 50 && worst_unguarded > 1e-11,
8395            "peeling cho_parmest unguarded should damage the entries the guard \
8396             exists to protect (measured: 88 entries past 1e-13 relative, \
8397             worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
8398             this fires, the model or the corpus changed and the guard's \
8399             calibration should be re-derived rather than the bound relaxed."
8400        );
8401        eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
8402    }
8403
8404    /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
8405    /// the dense variable is `x_{n-1}` instead of `x_0`:
8406    /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
8407    ///
8408    /// The index matters, and it is the whole reason this helper exists.
8409    /// With the dense variable at 0 every coupling entry is stored as
8410    /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
8411    /// out of column 0's own pass whether or not peeling is on, so the two
8412    /// paths are bit-identical and no test built on that shape can tell
8413    /// them apart. Putting the dense variable last stores them as
8414    /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
8415    /// back from column `n-1`'s pass by symmetry, carrying that pass's
8416    /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
8417    /// That is the category every one of `cho_parmest`'s 48,931 damaged
8418    /// entries fell into.
8419    fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
8420        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
8421        let d = n - 1;
8422        let mut s = String::new();
8423        s.push_str("g3 1 1 0\n");
8424        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8425        s.push_str(" 0 1\n 0 0\n");
8426        s.push_str(&format!(" {n} {n} {n}\n"));
8427        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8428        s.push_str(&format!(" 0 {n}\n"));
8429        s.push_str(" 0 0\n 0 0 0 0 0\n");
8430        s.push_str("O0 0\n");
8431        s.push_str(&format!("o54\n{}\n", n + 1));
8432        for j in 0..n {
8433            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8434        }
8435        s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
8436        for j in 0..d {
8437            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8438        }
8439        s.push_str(&format!("x{n}\n"));
8440        for j in 0..n {
8441            s.push_str(&format!("{j} 0.5\n"));
8442        }
8443        s.push_str("b\n");
8444        for _ in 0..n {
8445            s.push_str("3\n");
8446        }
8447        s.push_str(&format!("G0 {n}\n"));
8448        for j in 0..n {
8449            s.push_str(&format!("{j} 0.0\n"));
8450        }
8451        s
8452    }
8453
8454    /// A well-scaled dense column is still peeled, and the entries read
8455    /// out of its pass are exact — the property the peel guard must not
8456    /// cost us.
8457    #[test]
8458    fn a_well_scaled_dense_column_is_still_peeled() {
8459        let n = 200;
8460        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
8461        let t = NlTnlp::new(p);
8462        assert_eq!(
8463            t.peeled_cols,
8464            vec![0],
8465            "a dense column that costs no accuracy must stay peeled"
8466        );
8467        assert!(
8468            t.seeds.len() <= 4,
8469            "peeling should keep the color count at O(1), got {}",
8470            t.seeds.len()
8471        );
8472    }
8473
8474    /// An ill-scaled dense column must be un-peeled and colored the
8475    /// ordinary way, so its small entries come back to full precision.
8476    ///
8477    /// This test covers the *guard*, not the damage. It asserts that a
8478    /// column spanning 12 orders is un-peeled and that its entries are then
8479    /// exact — and it is worth being precise that it does not, and cannot,
8480    /// show what peeling would have cost, because on this model peeling
8481    /// costs nothing: force the peel through and every entry still comes
8482    /// back bit-identical to its analytic weight. The Hessian here is
8483    /// constant and each entry is a single product, so there is no
8484    /// accumulation for a large-magnitude pass to pollute.
8485    ///
8486    /// Reproducing the actual digit loss takes a model whose entries are
8487    /// summed through shared intermediates, which is why the demonstration
8488    /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
8489    /// real model rather than a synthetic one.
8490    #[test]
8491    fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
8492        let n = 200;
8493        let span = 12.0;
8494        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8495        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
8496        let mut t = NlTnlp::new(p);
8497
8498        assert!(
8499            t.peeled_cols.is_empty(),
8500            "a dense column spanning {span} orders must not be peeled; got {:?}",
8501            t.peeled_cols
8502        );
8503
8504        let info = t.get_nlp_info().unwrap();
8505        let nnz = info.nnz_h_lag as usize;
8506        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8507        assert!(t.eval_h(
8508            None,
8509            true,
8510            1.0,
8511            None,
8512            true,
8513            SparsityRequest::Structure {
8514                irow: &mut irow,
8515                jcol: &mut jcol
8516            }
8517        ));
8518        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8519        let mut vals = vec![0.0_f64; nnz];
8520        assert!(t.eval_h(
8521            Some(&x),
8522            true,
8523            1.0,
8524            None,
8525            true,
8526            SparsityRequest::Values { values: &mut vals }
8527        ));
8528
8529        // Every coupling entry must be its weight to full relative
8530        // precision. Peeled, the smallest of them came back with a
8531        // relative error near 1e-4.
8532        let mut checked = 0;
8533        for k in 0..nnz {
8534            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8535            if i == j {
8536                continue;
8537            }
8538            assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8539            checked += 1;
8540            let want = w(i);
8541            assert!(
8542                (vals[k] - want).abs() <= 1e-13 * want,
8543                "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
8544                vals[k],
8545                (vals[k] - want).abs() / want
8546            );
8547        }
8548        assert_eq!(checked, n - 1);
8549    }
8550
8551    /// Whatever survives the peel guard must decode to the same Hessian
8552    /// that not peeling at all produces — every entry, not just the
8553    /// objective.
8554    ///
8555    /// This exists because the original dense-column optimization was
8556    /// validated by running the repository's `.nl` fixtures and comparing
8557    /// objective value and exit status, which was doubly blind: not one of
8558    /// the 60 fixtures has a Hessian row dense enough to peel anything, so
8559    /// the decode path under test never executed, and even had it executed,
8560    /// the defect cost digits in the multipliers while leaving the
8561    /// objective intact. So the instrument has to be the assembled Hessian
8562    /// and the input has to actually peel — hence `peeled_any` below, which
8563    /// fails if the sweep ever goes vacuous the way the fixture suite
8564    /// silently did.
8565    ///
8566    /// What this catches is a *decode* fault: an entry recovered from the
8567    /// wrong pass, which is wrong by O(1). It would not have caught the
8568    /// `cho_parmest` stall, because these synthetic models lose no
8569    /// precision under peeling at all (see
8570    /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
8571    /// precision half is covered against the real model in
8572    /// `cho_parmest_decodes_to_its_unpeeled_reference`.
8573    #[test]
8574    fn a_peeled_decode_matches_an_unpeeled_reference() {
8575        // `last = true` puts the dense variable at `n-1`, so the coupling
8576        // entries are stored with the peeled column as their *row* — the
8577        // only shape in which peeling and not peeling read an entry out of
8578        // different passes, and so the only shape that can detect a
8579        // difference at all. See `weighted_dense_last_col_nl`.
8580        let cases = [
8581            (200, 0.0, false),
8582            (200, 2.0, false),
8583            (200, 0.0, true),
8584            (200, 1.0, true),
8585            (200, 2.0, true),
8586            (400, 3.0, true),
8587            (600, 0.0, true),
8588        ];
8589        let mut peeled_any = false;
8590        let mut worst = 0.0_f64;
8591
8592        for &(n, span, last) in &cases {
8593            let text = if last {
8594                weighted_dense_last_col_nl(n, span)
8595            } else {
8596                weighted_dense_row_objective_nl(n, span)
8597            };
8598            let p = parse_nl_text(&text).expect("parse");
8599            let mut t = NlTnlp::new(p);
8600            let peeled = t.peeled_cols.clone();
8601            peeled_any |= !peeled.is_empty();
8602
8603            let info = t.get_nlp_info().unwrap();
8604            let nnz = info.nnz_h_lag as usize;
8605            let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8606            assert!(t.eval_h(
8607                None,
8608                true,
8609                1.0,
8610                None,
8611                true,
8612                SparsityRequest::Structure {
8613                    irow: &mut irow,
8614                    jcol: &mut jcol
8615                }
8616            ));
8617            let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
8618
8619            let mut got = vec![0.0_f64; nnz];
8620            assert!(t.eval_h(
8621                Some(&x),
8622                true,
8623                1.0,
8624                None,
8625                true,
8626                SparsityRequest::Values { values: &mut got }
8627            ));
8628
8629            // Same object, same tapes, same point — only the coloring
8630            // differs, so any disagreement is the decode path.
8631            t.recolor(&vec![true; n]);
8632            assert!(
8633                t.peeled_cols.is_empty(),
8634                "a fully vetoed model must peel nothing"
8635            );
8636            let mut want = vec![0.0_f64; nnz];
8637            assert!(t.eval_h(
8638                Some(&x),
8639                true,
8640                1.0,
8641                None,
8642                true,
8643                SparsityRequest::Values { values: &mut want }
8644            ));
8645
8646            for k in 0..nnz {
8647                let scale = want[k].abs().max(f64::MIN_POSITIVE);
8648                let rel = (got[k] - want[k]).abs() / scale;
8649                worst = worst.max(rel);
8650                assert!(
8651                    rel <= 1e-13,
8652                    "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
8653                     unpeeled reference {:e} (relative error {rel:e})",
8654                    irow[k],
8655                    jcol[k],
8656                    got[k],
8657                    want[k]
8658                );
8659            }
8660        }
8661
8662        assert!(
8663            peeled_any,
8664            "no case peeled anything, so this test proved nothing about the \
8665             decode path — the exact way the fixture suite missed the bug"
8666        );
8667        assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
8668    }
8669
8670    /// `peel_veto` bars a column from the peel set, and the row it puts
8671    /// back into the conflict structure costs the colors it used to save.
8672    #[test]
8673    fn a_vetoed_column_is_colored_the_ordinary_way() {
8674        let n = 300;
8675        // One dense row plus a diagonal: column 0 touches every row.
8676        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8677        pairs.extend((1..n).map(|i| (i, 0)));
8678        pairs.sort_unstable();
8679
8680        let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8681        assert!(peeled[0], "the dense column should peel by default");
8682        assert!(
8683            colors_peeled <= 4,
8684            "peeling should collapse the count, got {colors_peeled}"
8685        );
8686
8687        let mut veto = vec![false; n];
8688        veto[0] = true;
8689        let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
8690        assert!(!peeled[0], "a vetoed column must not be peeled");
8691        assert!(
8692            colors_vetoed > colors_peeled,
8693            "un-peeling restores row 0's conflicts, so colors must rise: \
8694             {colors_vetoed} vs {colors_peeled}"
8695        );
8696    }
8697
8698    /// A single dense Hessian row must not blow the coloring up to one
8699    /// color per variable. It used to: every column shares row 0, so no
8700    /// two columns could be colored alike, and `seeds` / `compressed`
8701    /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
8702    /// per `eval_h` on a Hessian holding only ~2n nonzeros.
8703    #[test]
8704    fn a_dense_hessian_row_does_not_explode_the_coloring() {
8705        let n = 200;
8706        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8707        let t = NlTnlp::new(p);
8708        // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
8709        // for j >= 1 from the coupling term.
8710        assert_eq!(t.h_irow.len(), 2 * n - 1);
8711        assert!(
8712            t.seeds.len() <= 4,
8713            "one dense row should cost one extra color, not n; got {} colors for n={n}",
8714            t.seeds.len()
8715        );
8716        assert_eq!(t.seeds.len(), t.compressed.len());
8717    }
8718
8719    /// Peeling changes *which* directional product recovers an entry, so
8720    /// the recovered Hessian must still be exactly right — including the
8721    /// entries read out of a peeled column's pass by symmetry.
8722    #[test]
8723    fn peeled_dense_column_still_recovers_the_exact_hessian() {
8724        let n = 200;
8725        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8726        let mut t = NlTnlp::new(p);
8727        let info = t.get_nlp_info().unwrap();
8728        let nnz = info.nnz_h_lag as usize;
8729
8730        let mut irow = vec![0_i32; nnz];
8731        let mut jcol = vec![0_i32; nnz];
8732        assert!(t.eval_h(
8733            None,
8734            true,
8735            1.0,
8736            None,
8737            true,
8738            SparsityRequest::Structure {
8739                irow: &mut irow,
8740                jcol: &mut jcol
8741            }
8742        ));
8743
8744        // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
8745        //   H[0,0] = 2 + 2 = 4;  H[j,j] = 2 (j >= 1);  H[j,0] = 1 (j >= 1).
8746        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8747        let obj_factor = 2.5;
8748        let mut vals = vec![0.0_f64; nnz];
8749        assert!(t.eval_h(
8750            Some(&x),
8751            true,
8752            obj_factor,
8753            None,
8754            true,
8755            SparsityRequest::Values { values: &mut vals }
8756        ));
8757
8758        let mut seen_diag = 0;
8759        let mut seen_coupling = 0;
8760        for k in 0..nnz {
8761            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8762            let want = if i == 0 && j == 0 {
8763                4.0
8764            } else if i == j {
8765                seen_diag += 1;
8766                2.0
8767            } else {
8768                assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8769                seen_coupling += 1;
8770                1.0
8771            } * obj_factor;
8772            assert!(
8773                (vals[k] - want).abs() < 1e-12,
8774                "H[{i},{j}] = {}, want {want}",
8775                vals[k]
8776            );
8777        }
8778        assert_eq!(seen_diag, n - 1);
8779        assert_eq!(seen_coupling, n - 1);
8780    }
8781
8782    /// The peeling threshold must leave ordinary sparse models on
8783    /// exactly the coloring they had before: a banded Hessian colors by
8784    /// its bandwidth, with nothing peeled.
8785    #[test]
8786    fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
8787        let n = 400;
8788        let pairs: Vec<(usize, usize)> = (0..n)
8789            .flat_map(|j| {
8790                let mut v = vec![(j, j)];
8791                if j + 1 < n {
8792                    v.push((j + 1, j));
8793                }
8794                v
8795            })
8796            .collect();
8797        let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8798        assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
8799        assert!(
8800            n_colors <= 3,
8801            "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
8802        );
8803        assert!(var_color.iter().all(|&c| c != u32::MAX));
8804    }
8805
8806    /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
8807    /// scattered through an otherwise diagonal `n`-variable problem.
8808    /// A plain coloring needs exactly `size` colors no matter how many
8809    /// blocks there are, because the blocks share no rows.
8810    fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
8811        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8812        let stride = n / blocks;
8813        for b in 0..blocks {
8814            let base = b * stride;
8815            for i in 0..size {
8816                for j in 0..=i {
8817                    if i != j {
8818                        pairs.push((base + i, base + j));
8819                    }
8820                }
8821            }
8822        }
8823        pairs
8824    }
8825
8826    /// Many medium-degree columns must not be peeled.
8827    ///
8828    /// Regression: selecting candidates by "degree > 16x average" and then
8829    /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
8830    /// The columns that miss the cut stay in the conflict structure, so the
8831    /// base color count is untouched and the singleton colors are pure
8832    /// addition — these two patterns colored to 306 and 290 against a plain
8833    /// walk's 50 and 34.
8834    #[test]
8835    fn thousands_of_medium_degree_cols_are_not_peeled() {
8836        for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
8837            let pairs = disjoint_blocks(n, blocks, size);
8838            let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8839            let n_peeled = peeled.iter().filter(|&&p| p).count();
8840            assert_eq!(
8841                n_peeled, 0,
8842                "degree-{size} columns do not pay for a singleton color \
8843                 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
8844            );
8845            assert!(
8846                n_colors <= size + 1,
8847                "disjoint {size}x{size} blocks color by block size regardless \
8848                 of block count; got {n_colors} for n={n}, blocks={blocks}"
8849            );
8850        }
8851    }
8852
8853    /// The pay-for-itself rule must not throw away the case peeling exists
8854    /// for: a handful of genuinely dense rows still get peeled, and still
8855    /// collapse the coloring.
8856    #[test]
8857    fn a_few_truly_dense_rows_are_still_peeled() {
8858        let n = 5_000;
8859        let dense_rows = 4;
8860        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8861        for d in 0..dense_rows {
8862            for j in 0..n {
8863                if j != d {
8864                    pairs.push((j.max(d), j.min(d)));
8865                }
8866            }
8867        }
8868        let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8869        let n_peeled = peeled.iter().filter(|&&p| p).count();
8870        assert_eq!(n_peeled, dense_rows, "every full row should peel");
8871        assert!(
8872            n_colors <= dense_rows + 2,
8873            "peeling {dense_rows} full rows should leave a diagonal remainder, \
8874             got {n_colors} colors"
8875        );
8876    }
8877
8878    /// The cap still binds, and when it does the kept columns are the
8879    /// highest-degree ones.
8880    #[test]
8881    fn peeling_is_capped_and_keeps_the_worst_offenders() {
8882        let n = 20_000;
8883        let dense_rows = 400;
8884        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8885        for d in 0..dense_rows {
8886            // Row d touches the first (n - d) columns, so degree strictly
8887            // decreases with d and the ordering is unambiguous.
8888            for j in 0..(n - d) {
8889                if j != d {
8890                    pairs.push((j.max(d), j.min(d)));
8891                }
8892            }
8893        }
8894        let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8895        let n_peeled = peeled.iter().filter(|&&p| p).count();
8896        assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
8897        assert!(
8898            (0..MAX_PEELED_COLS).all(|d| peeled[d]),
8899            "the {MAX_PEELED_COLS} densest rows are the ones kept"
8900        );
8901    }
8902
8903    /// Header line 0 is `g<count> <opt0> ...`; the option words are the
8904    /// model's own and a solver echoes them into the `.sol` `Options`
8905    /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
8906    #[test]
8907    fn header_option_words_are_kept_verbatim() {
8908        let p = parse_nl_text(EQ_LIN).expect("parse");
8909        assert_eq!(p.ampl_options, vec![0, 1, 0]);
8910    }
8911
8912    /// A count that does not match the words present must not be
8913    /// guessed at — the writer falls back rather than emit a wrong block.
8914    #[test]
8915    fn a_truncated_option_list_is_dropped_not_padded() {
8916        let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
8917        let p = parse_nl_text(&text).expect("parse");
8918        assert!(
8919            p.ampl_options.is_empty(),
8920            "9 declared but 3 present: {:?}",
8921            p.ampl_options
8922        );
8923    }
8924
8925    #[test]
8926    fn constrained_tnlp_eval_g_jac_h() {
8927        let p = parse_nl_text(EQ_LIN).expect("parse");
8928        let mut t = NlTnlp::new(p);
8929        let info = t.get_nlp_info().unwrap();
8930        assert_eq!(info.m, 1);
8931        assert_eq!(info.nnz_jac_g, 2);
8932
8933        // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
8934        let mut g = [0.0_f64; 1];
8935        assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
8936        assert!((g[0] - 0.7).abs() < 1e-12);
8937
8938        // Jacobian structure: row 0, cols [0, 1].
8939        let mut irow = [0_i32; 2];
8940        let mut jcol = [0_i32; 2];
8941        assert!(t.eval_jac_g(
8942            None,
8943            true,
8944            SparsityRequest::Structure {
8945                irow: &mut irow,
8946                jcol: &mut jcol
8947            }
8948        ));
8949        assert_eq!(irow, [0, 0]);
8950        assert_eq!(jcol, [0, 1]);
8951
8952        // Jacobian values: both 1.0.
8953        let mut vals = [0.0_f64; 2];
8954        assert!(t.eval_jac_g(
8955            Some(&[0.3, 0.4]),
8956            true,
8957            SparsityRequest::Values { values: &mut vals }
8958        ));
8959        assert!((vals[0] - 1.0).abs() < 1e-12);
8960        assert!((vals[1] - 1.0).abs() < 1e-12);
8961
8962        // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
8963        // λ contributes nothing because the constraint is linear, and
8964        // x0^2 + x1^2 is separable so there's no (1,0) entry in the
8965        // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
8966        assert_eq!(info.nnz_h_lag, 2);
8967        let mut hirow = [0_i32; 2];
8968        let mut hjcol = [0_i32; 2];
8969        assert!(t.eval_h(
8970            None,
8971            true,
8972            1.0,
8973            None,
8974            true,
8975            SparsityRequest::Structure {
8976                irow: &mut hirow,
8977                jcol: &mut hjcol
8978            }
8979        ));
8980        assert_eq!(hirow, [0, 1]);
8981        assert_eq!(hjcol, [0, 1]);
8982        let mut hvals = [0.0_f64; 2];
8983        assert!(t.eval_h(
8984            Some(&[0.3, 0.4]),
8985            true,
8986            1.0,
8987            Some(&[0.5]),
8988            true,
8989            SparsityRequest::Values { values: &mut hvals }
8990        ));
8991        assert!((hvals[0] - 2.0).abs() < 1e-12);
8992        assert!((hvals[1] - 2.0).abs() < 1e-12);
8993    }
8994
8995    /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
8996    /// encoded as common-subexpression `V2`. Header line 10 declares
8997    /// one obj-only CSE; expression tree references `v2` twice.
8998    const CSE_OBJ: &str = "g3 0 1 0
89992 0 1 0 0
90000 1
90010 0
90020 2 0
90030 0 0 1
90040 0 0 0 0
90050 0
90060 0
90070 1 0 0 0
9008V2 0 0
9009o0
9010v0
9011v1
9012O0 0
9013o0
9014o5
9015v2
9016n2
9017v2
9018b
90193
90203
9021";
9022
9023    #[test]
9024    fn parses_v_segment_cse() {
9025        let p = parse_nl_text(CSE_OBJ).expect("parse");
9026        assert_eq!(p.n, 2);
9027        // f(1,2) = 9 + 3 = 12
9028        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
9029        assert!((f - 12.0).abs() < 1e-12, "got {f}");
9030        // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
9031        let mut g = [0.0_f64; 2];
9032        grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
9033        assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
9034        assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
9035        // collect_vars reaches into the CSE body and finds {0, 1}.
9036        let mut vs = BTreeSet::new();
9037        p.obj_nonlinear.collect_vars(&mut vs);
9038        assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
9039    }
9040
9041    /// `min (x0 - 1)^2` with three suffix segments attached: an
9042    /// integer constraint-suffix (target=1, kind=1), an integer var-
9043    /// suffix (target=0, kind=0), and a real var-suffix (target=0,
9044    /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
9045    /// `<idx> <value>` lines.
9046    const WITH_SUFFIXES: &str = "g3 0 1 0
90471 0 1 0 0
90480 1
90490 0
90500 1 0
90510 0 0 1
90520 0 0 0 0
90530 0
90540 0
90550 0 0 0 0
9056O0 0
9057o5
9058o1
9059v0
9060n1
9061n2
9062b
90633
9064S0 1 sens_state_1
90650 7
9066S4 1 sens_state_value_1
90670 4.5
9068";
9069
9070    #[test]
9071    fn parses_var_int_and_var_real_suffixes() {
9072        let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
9073        // Integer var-suffix: dense length 1, slot 0 = 7.
9074        let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
9075        assert_eq!(v.as_slice(), &[7]);
9076        // Real var-suffix: dense length 1, slot 0 = 4.5.
9077        let r = p
9078            .suffixes
9079            .var_real
9080            .get("sens_state_value_1")
9081            .expect("var_real");
9082        assert_eq!(r.len(), 1);
9083        assert!((r[0] - 4.5).abs() < 1e-12);
9084        // Other suffix slots stay empty.
9085        assert!(p.suffixes.con_int.is_empty());
9086        assert!(p.suffixes.con_real.is_empty());
9087    }
9088
9089    /// Two-variable + two-constraint problem with a constraint-level
9090    /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
9091    const WITH_CON_SUFFIX: &str = "g3 0 1 0
90922 2 1 0 0
90930 0
90940 0
90950 2 0
90960 0 0 1
90970 0 0 0 0
90984 0
90990 0
91000 0 0 0 0 0
9101C0
9102n0
9103C1
9104n0
9105O0 0
9106n0
9107r
91084 0.0
91094 0.0
9110b
91113
91123
9113k1
91140
9115J0 2
91160 1
91171 1
9118J1 2
91190 1
91201 -1
9121S1 2 sens_init_constr
91220 1
91231 2
9124";
9125
9126    #[test]
9127    fn parses_con_int_suffix() {
9128        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9129        let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
9130        // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
9131        assert_eq!(s.as_slice(), &[1, 2]);
9132    }
9133
9134    // ---- gh#785: a truncated file is rejected, not silently defaulted ----
9135    //
9136    // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
9137    // segment boundary is exactly the failure the issue reports: an
9138    // interrupted write. Each cut loses a different first segment and each
9139    // is caught by a different check, so all three are asserted — and the
9140    // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
9141    // what keeps these from passing against a parser that rejects
9142    // everything.
9143
9144    /// Everything from `at` (a segment header line) onward is gone.
9145    fn truncate_before(txt: &str, at: &str) -> String {
9146        let cut = txt
9147            .find(at)
9148            .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
9149        txt[..cut].to_string()
9150    }
9151
9152    #[test]
9153    fn truncation_before_the_row_bounds_is_a_parse_error() {
9154        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
9155            .expect_err("truncated file must not parse");
9156        assert!(
9157            err.contains("`r` (constraint-bounds) segment"),
9158            "error should name the missing segment: {err}"
9159        );
9160    }
9161
9162    #[test]
9163    fn truncation_before_the_variable_bounds_is_a_parse_error() {
9164        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
9165            .expect_err("truncated file must not parse");
9166        assert!(
9167            err.contains("`b` (variable-bounds) segment"),
9168            "error should name the missing segment: {err}"
9169        );
9170    }
9171
9172    /// The cut that leaves every bound in place and takes only the
9173    /// coefficients. Neither presence check can see it; the declared-vs-
9174    /// parsed nonzero count is the whole of the evidence.
9175    #[test]
9176    fn truncation_before_the_jacobian_is_a_parse_error() {
9177        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
9178            .expect_err("truncated file must not parse");
9179        assert!(
9180            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
9181            "error should report the mismatch: {err}"
9182        );
9183    }
9184
9185    /// The mismatch is an equality, not a floor: a file supplying *more*
9186    /// entries than it declares is as corrupt as one supplying fewer, and a
9187    /// `>=` check would wave it through.
9188    #[test]
9189    fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
9190        let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
9191        let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
9192        assert!(
9193            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
9194            "error should report the mismatch: {err}"
9195        );
9196    }
9197
9198    /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
9199    /// (n = m = 2) and hand back everything the engine would see.
9200    fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
9201        let mut obj = 1.0;
9202        let mut use_x = false;
9203        let mut x = vec![0.0; 2];
9204        let mut use_g = false;
9205        let mut g = vec![0.0; 2];
9206        let ok = tnlp.get_scaling_parameters(ScalingRequest {
9207            obj_scaling: &mut obj,
9208            use_x_scaling: &mut use_x,
9209            x_scaling: &mut x,
9210            use_g_scaling: &mut use_g,
9211            g_scaling: &mut g,
9212        });
9213        (ok, obj, use_x, x, use_g, g)
9214    }
9215
9216    /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
9217    /// the objective (`S6`) and one constraint (`S5`) reaches the
9218    /// engine's `user-scaling` pathway. The untagged second row is
9219    /// unscaled — its AMPL suffix default is 0, which is not a usable
9220    /// scale factor and reads as "not tagged".
9221    #[test]
9222    fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
9223        let nl = WITH_CON_SUFFIX.to_string()
9224            + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
9225        let p = parse_nl_text(&nl).expect("parse");
9226        let mut tnlp = NlTnlp::new(p);
9227        let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
9228        assert!(ok, "a tagged model must supply scaling");
9229        assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
9230        assert!(use_g);
9231        assert_eq!(g, vec![10.0, 1.0]);
9232        assert!(!use_x, "no variable suffix was declared");
9233    }
9234
9235    /// Variable-level `scaling_factor` entries are passed through, not
9236    /// dropped on the floor: `OrigIpoptNlp` does not model them and
9237    /// refuses the solve, which is the whole point of gh#483.
9238    #[test]
9239    fn scaling_factor_suffix_forwards_variable_factors() {
9240        let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
9241        let p = parse_nl_text(&nl).expect("parse");
9242        let mut tnlp = NlTnlp::new(p);
9243        let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
9244        assert!(ok);
9245        assert!(use_x, "variable factors must reach the engine");
9246        assert_eq!(x, vec![1.0, 3.0]);
9247    }
9248
9249    /// gh #703: with curvature-based scaling switched on, the computed
9250    /// factors are the base and a `scaling_factor` suffix the model
9251    /// actually carries wins **component by component** — an explicit
9252    /// factor from the modeller beats one inferred from the coefficients,
9253    /// and the components they did not tag keep the inferred one rather
9254    /// than snapping back to 1.
9255    #[test]
9256    fn a_user_suffix_overrides_the_computed_factor_component_wise() {
9257        let baseline = {
9258            let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9259            let mut t = NlTnlp::new(p);
9260            assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
9261            scaling_of(&mut t).5
9262        };
9263        assert!(
9264            baseline.iter().all(|v| *v > 0.0),
9265            "curvature scaling should produce usable row factors, got {baseline:?}"
9266        );
9267
9268        // Tag row 0 only. AMPL's untagged default is 0, which must read as
9269        // "not tagged" and leave row 1 on the computed factor.
9270        let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
9271        let p = parse_nl_text(&nl).expect("parse");
9272        let mut t = NlTnlp::new(p);
9273        assert!(t.enable_curvature_scaling());
9274        let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
9275        assert!(ok);
9276        assert!(use_g && use_x);
9277        assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
9278        assert_eq!(
9279            g[1], baseline[1],
9280            "the untagged row keeps the computed one, not 1.0"
9281        );
9282    }
9283
9284    /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
9285    /// same answer the default `TNLP` impl gives, so `user-scaling`
9286    /// falls back to no scaling instead of a bogus all-zero vector.
9287    #[test]
9288    fn no_scaling_factor_suffix_declines() {
9289        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9290        let mut tnlp = NlTnlp::new(p);
9291        let (ok, ..) = scaling_of(&mut tnlp);
9292        assert!(!ok);
9293    }
9294
9295    #[test]
9296    fn rejects_suffix_with_out_of_range_index() {
9297        let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
9298        let err = parse_nl_text(&bad).expect_err("must reject");
9299        assert!(
9300            err.contains("out of range"),
9301            "expected out-of-range error, got: {err}"
9302        );
9303    }
9304
9305    #[test]
9306    fn tnlp_round_trip_solves() {
9307        let p = parse_nl_text(SIMPLE).expect("parse");
9308        let mut tnlp = NlTnlp::new(p);
9309        let info = tnlp.get_nlp_info().unwrap();
9310        assert_eq!(info.n, 2);
9311        assert_eq!(info.m, 0);
9312        let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
9313        assert!((f0 - 5.0).abs() < 1e-12);
9314        let mut g = [0.0_f64; 2];
9315        tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
9316        // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
9317        assert!((g[0] - (-2.0)).abs() < 1e-12);
9318        assert!((g[1] - (-4.0)).abs() < 1e-12);
9319    }
9320
9321    // ---- Sibling `.col` / `.row` name-file capture --------------------
9322    //
9323    // Names let diagnostics name the offending equation instead of "row 3"
9324    // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
9325    // the read path and the documented fallback-to-empty behavior.
9326
9327    use pounce_nlp::expression_provider::ExpressionProvider;
9328    use std::sync::atomic::{AtomicUsize, Ordering};
9329
9330    /// Unique scratch dir for one test (no `tempfile` dev-dep available).
9331    fn scratch_dir(tag: &str) -> std::path::PathBuf {
9332        static N: AtomicUsize = AtomicUsize::new(0);
9333        let seq = N.fetch_add(1, Ordering::Relaxed);
9334        let dir = std::env::temp_dir().join(format!(
9335            "pounce_nlnames_{}_{}_{}",
9336            std::process::id(),
9337            tag,
9338            seq
9339        ));
9340        std::fs::create_dir_all(&dir).expect("create scratch dir");
9341        dir
9342    }
9343
9344    #[test]
9345    fn read_name_file_reads_in_order() {
9346        let dir = scratch_dir("col_order");
9347        let p = dir.join("m.col");
9348        std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
9349        assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
9350    }
9351
9352    #[test]
9353    fn read_name_file_truncates_extra_lines() {
9354        // `.row` conventionally appends the objective name after the m
9355        // constraint names; `.take(expected)` must drop it so names stay
9356        // 1:1 with `g`.
9357        let dir = scratch_dir("row_obj");
9358        let p = dir.join("m.row");
9359        std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
9360        assert_eq!(
9361            read_name_file(&p, 2),
9362            vec!["mass_balance", "energy_balance"]
9363        );
9364    }
9365
9366    #[test]
9367    fn read_name_file_empty_on_short_or_missing() {
9368        let dir = scratch_dir("short");
9369        let short = dir.join("m.col");
9370        std::fs::write(&short, "only_one\n").unwrap();
9371        // Fewer lines than expected ⇒ empty (never a partial mapping).
9372        assert!(read_name_file(&short, 3).is_empty());
9373        // Missing file ⇒ empty, no error.
9374        assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
9375    }
9376
9377    #[test]
9378    fn read_nl_file_captures_sibling_names() {
9379        // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
9380        // names ride through onto the TNLP's ExpressionProvider.
9381        let dir = scratch_dir("sibling");
9382        let nl = dir.join("m.nl");
9383        std::fs::write(&nl, SIMPLE).unwrap();
9384        std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
9385
9386        let prob = read_nl_file(&nl).expect("parse + name capture");
9387        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9388        assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
9389
9390        let tnlp = NlTnlp::new(prob);
9391        assert_eq!(tnlp.variable_name(0), Some("alpha"));
9392        assert_eq!(tnlp.variable_name(1), Some("beta"));
9393        assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
9394    }
9395
9396    #[test]
9397    fn read_nl_file_without_names_yields_empty() {
9398        let dir = scratch_dir("noname");
9399        let nl = dir.join("m.nl");
9400        std::fs::write(&nl, SIMPLE).unwrap();
9401        let prob = read_nl_file(&nl).expect("parse");
9402        assert!(prob.var_names.is_empty());
9403        assert!(prob.con_names.is_empty());
9404        let tnlp = NlTnlp::new(prob);
9405        assert_eq!(tnlp.variable_name(0), None);
9406    }
9407
9408    #[test]
9409    fn read_nl_file_resolves_extensionless_ampl_stub() {
9410        // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
9411        // the `.nl` extension; the solver must read `mystub.nl`. Code
9412        // review 2026-06 item M15.
9413        let dir = scratch_dir("stub");
9414        std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
9415        // Pass the extensionless stub — the file `mystub` does not exist.
9416        let stub = dir.join("mystub");
9417        assert!(!stub.exists(), "stub must be extensionless / absent");
9418        let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
9419        assert_eq!(prob.n, 2);
9420        assert_eq!(prob.m, 0);
9421
9422        // Sibling name files are still found off the resolved stem.
9423        std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
9424        let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
9425        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9426    }
9427
9428    #[test]
9429    fn read_nl_file_prefers_exact_path_over_nl_sibling() {
9430        // An existing path is read verbatim — the `.nl` fallback only
9431        // kicks in when the literal path is missing, so a caller passing a
9432        // real file is never silently redirected to a `<file>.nl` sibling.
9433        let dir = scratch_dir("exact");
9434        // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
9435        std::fs::write(dir.join("data"), SIMPLE).unwrap();
9436        std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
9437        let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
9438        assert_eq!(prob.n, 2);
9439    }
9440
9441    #[test]
9442    fn append_extension_appends_rather_than_replaces() {
9443        use std::path::Path;
9444        assert_eq!(
9445            append_extension(Path::new("mystub"), "nl"),
9446            Path::new("mystub.nl")
9447        );
9448        // A stub that itself contains a dot keeps its stem (AMPL names it
9449        // `my.model.nl`, not `my.nl`).
9450        assert_eq!(
9451            append_extension(Path::new("my.model"), "nl"),
9452            Path::new("my.model.nl")
9453        );
9454    }
9455
9456    // ---- equation rendering (`print equation`) ----
9457
9458    fn names(v: &[&str]) -> Vec<String> {
9459        v.iter().map(|s| s.to_string()).collect()
9460    }
9461
9462    #[test]
9463    fn render_uses_variable_names_when_present() {
9464        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9465        assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
9466        // Falls back to x[i] when names are absent.
9467        assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
9468    }
9469
9470    #[test]
9471    fn render_parenthesizes_by_precedence() {
9472        // (x0 + x1) * x2  must keep the parens around the sum.
9473        let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9474        let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
9475        assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
9476
9477        // x0 + x1 * x2  needs no parens (mul binds tighter).
9478        let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9479        let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
9480        assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
9481    }
9482
9483    #[test]
9484    fn render_subtraction_right_assoc_parens() {
9485        // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
9486        let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9487        let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
9488        assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
9489    }
9490
9491    #[test]
9492    fn render_functions_and_pow() {
9493        let sq = Expr::Binary(
9494            BinOp::Pow,
9495            Box::new(Expr::Var(0)),
9496            Box::new(Expr::Const(2.0)),
9497        );
9498        let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
9499        assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
9500    }
9501
9502    #[test]
9503    fn render_linear_signs_are_tidy() {
9504        // 1*a - 2*b + c  (coef +1 omits the multiplier).
9505        let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
9506        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
9507    }
9508
9509    #[test]
9510    fn render_linear_skips_zero_coefficients() {
9511        // A 0 coefficient (a variable present only in the nonlinear part)
9512        // is dropped, not rendered as `0*x`.
9513        let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
9514        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
9515        // Leading term zero ⇒ the first emitted term still has no ` + `.
9516        let lin = vec![(0usize, 0.0), (1, 2.0)];
9517        assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
9518    }
9519
9520    #[test]
9521    fn render_sum_folds_negative_terms() {
9522        // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
9523        let sq = |i| {
9524            Expr::Binary(
9525                BinOp::Pow,
9526                Box::new(Expr::Var(i)),
9527                Box::new(Expr::Const(2.0)),
9528            )
9529        };
9530        let neg = |i| {
9531            Expr::Binary(
9532                BinOp::Mul,
9533                Box::new(Expr::Const(-1.0)),
9534                Box::new(Expr::Var(i)),
9535            )
9536        };
9537        let e = Expr::Sum(vec![
9538            sq(0),
9539            neg(1),
9540            Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
9541        ]);
9542        assert_eq!(
9543            render_expr(&e, &names(&["a", "b", "c"]), &[]),
9544            "a^2 - 1*b - c"
9545        );
9546    }
9547
9548    #[test]
9549    fn render_constraint_equation_forms() {
9550        // Build a 2-constraint problem by hand: an equality and a range.
9551        let mut prob = parse_nl_text(SIMPLE).unwrap();
9552        // Overwrite to a known small shape: 1 var, 2 cons.
9553        prob.n = 2;
9554        prob.m = 2;
9555        prob.var_names = names(&["mass_in", "mass_out"]);
9556        prob.con_names = names(&["balance", "window"]);
9557        prob.con_linear = vec![
9558            vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
9559            vec![(0, 1.0)],            // mass_in
9560        ];
9561        prob.con_nonlinear = vec![
9562            NlBody::Tree(Expr::Const(0.0)),
9563            NlBody::Tree(Expr::Const(0.0)),
9564        ];
9565        prob.g_l = vec![0.0, 0.0];
9566        prob.g_u = vec![0.0, 500.0];
9567
9568        assert_eq!(
9569            render_constraint_equation(&prob, 0),
9570            "mass_in - mass_out = 0"
9571        );
9572        assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
9573
9574        let all = render_all_constraint_equations(&prob);
9575        assert_eq!(all.len(), 2);
9576        assert_eq!(all[1], "0 <= mass_in <= 500");
9577    }
9578
9579    #[test]
9580    fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
9581        let mut prob = parse_nl_text(SIMPLE).unwrap();
9582        prob.n = 3;
9583        prob.m = 2;
9584        // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
9585        // Row 1: linear in x2 only → support {2}.
9586        prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
9587        prob.con_nonlinear = vec![
9588            NlBody::Tree(Expr::Binary(
9589                BinOp::Mul,
9590                Box::new(Expr::Var(0)),
9591                Box::new(Expr::Var(2)),
9592            )),
9593            NlBody::Tree(Expr::Const(0.0)),
9594        ];
9595        prob.g_l = vec![0.0, 0.0];
9596        prob.g_u = vec![0.0, 0.0];
9597
9598        let (irow, jcol) = constraint_jacobian_sparsity(&prob);
9599        // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
9600        assert_eq!(irow, vec![0, 0, 0, 1]);
9601        assert_eq!(jcol, vec![0, 1, 2, 2]);
9602    }
9603
9604    #[test]
9605    fn funcall_string_arg_with_hash_is_not_truncated() {
9606        // Code review L31: an AMPL string argument is a Hollerith literal
9607        // `h<len>:<chars>` whose content is exactly <len> bytes and may
9608        // legitimately contain '#' (e.g. a parameters-directory path). The
9609        // old parser ran strip_comment() over the line first, truncating
9610        // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
9611        let mut p = Parser::new("h3:a#b\n", false);
9612        match p.parse_funcall_arg().expect("parse hollerith arg") {
9613            FuncallArg::Str(s) => assert_eq!(s, "a#b"),
9614            other => panic!("expected Str, got {other:?}"),
9615        }
9616    }
9617
9618    #[test]
9619    fn funcall_string_arg_honors_declared_length() {
9620        // The declared `<len>` is authoritative: exactly that many bytes
9621        // after the ':' form the string; trailing content (here a real
9622        // ` # comment`) is not part of it.
9623        let mut p = Parser::new("h3:abc # trailing comment\n", false);
9624        match p.parse_funcall_arg().expect("parse hollerith arg") {
9625            FuncallArg::Str(s) => assert_eq!(s, "abc"),
9626            other => panic!("expected Str, got {other:?}"),
9627        }
9628    }
9629
9630    // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
9631    //
9632    // AMPL emits these in place of the general `o5` (OPPOW) when one operand
9633    // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
9634    // negative-base-safe constant-power lowering applies. The eval points below
9635    // are chosen to pin down BOTH the arity and the operand order: a swapped
9636    // `base`/`exp` (or treating `o82` as a different unary op) gives a
9637    // different number at these points, so each assertion is discriminating.
9638
9639    /// Parse a single expression `expr_src` with `n` variables in scope,
9640    /// driving the real `parse_opcode` path through `parse_expr`.
9641    fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
9642        let mut p = Parser::new(expr_src, false);
9643        p.n = n;
9644        p.parse_expr().expect("parse expression")
9645    }
9646
9647    #[test]
9648    fn opcode_o82_square_is_unary_pow_of_two() {
9649        // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
9650        let e = parse_one_expr(1, "o82\nv0\n");
9651        match &e {
9652            Expr::Binary(BinOp::Pow, base, exp) => {
9653                assert!(matches!(**base, Expr::Var(0)));
9654                match **exp {
9655                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
9656                    ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
9657                }
9658            }
9659            other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
9660        }
9661        // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
9662        // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
9663        assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
9664        assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
9665        // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
9666        let mut g = [0.0_f64; 1];
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        g[0] = 0.0;
9670        grad_expr(&e, &[-3.0], 1.0, &mut g);
9671        assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
9672    }
9673
9674    #[test]
9675    fn opcode_o81_const_exponent_is_base_pow_const() {
9676        // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
9677        let e = parse_one_expr(1, "o81\nv0\nn3\n");
9678        match &e {
9679            Expr::Binary(BinOp::Pow, base, exp) => {
9680                assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
9681                match **exp {
9682                    Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
9683                    ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
9684                }
9685            }
9686            other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
9687        }
9688        // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
9689        assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
9690        // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
9691        // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
9692        assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
9693        // gradient d/dx x^3 = 3x^2 = 12 at x=2.
9694        let mut g = [0.0_f64; 1];
9695        grad_expr(&e, &[2.0], 1.0, &mut g);
9696        assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
9697    }
9698
9699    #[test]
9700    fn opcode_o83_const_base_is_const_pow_exp() {
9701        // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
9702        let e = parse_one_expr(1, "o83\nn2\nv0\n");
9703        match &e {
9704            Expr::Binary(BinOp::Pow, base, exp) => {
9705                match **base {
9706                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
9707                    ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
9708                }
9709                assert!(
9710                    matches!(**exp, Expr::Var(0)),
9711                    "exponent must be the variable"
9712                );
9713            }
9714            other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
9715        }
9716        // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
9717        assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
9718        assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
9719        // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
9720        let mut g = [0.0_f64; 1];
9721        grad_expr(&e, &[3.0], 1.0, &mut g);
9722        assert!(
9723            (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
9724            "grad at 3 = {} (want {})",
9725            g[0],
9726            8.0 * 2.0_f64.ln()
9727        );
9728    }
9729
9730    #[test]
9731    fn power_specializations_agree_with_general_o5() {
9732        // Where both are defined, o81/o82/o83 must be numerically identical to
9733        // the general `o5` pow on the same operands — they are only routing
9734        // hints, not different math.
9735        let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
9736        let o82 = parse_one_expr(1, "o82\nv0\n");
9737        let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
9738        let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
9739        let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
9740        let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
9741        for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
9742            assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
9743            assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
9744            // 2^x is real for all x; compare across the same points.
9745            assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
9746        }
9747    }
9748
9749    #[test]
9750    fn power_opcodes_round_trip_through_parse_nl_text() {
9751        // End-to-end through the public entry point: `min x0^2 + x1^2` written
9752        // with o82 (square) parses and evaluates like its o5 twin. Reuses the
9753        // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
9754        let nl = SIMPLE.replace(
9755            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9756            "o0\no82\nv0\no82\nv1\n",
9757        );
9758        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9759        let p = parse_nl_text(&nl).expect("parse o82 objective");
9760        // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
9761        assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
9762        assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
9763    }
9764
9765    #[test]
9766    fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
9767        // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
9768        // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
9769        // (the negative-base-safe path) rather than a generic `powf`. The check
9770        // at a NEGATIVE base is the one that would break if o81 wrongly routed
9771        // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
9772        let nl = SIMPLE.replace(
9773            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9774            "o0\no81\nv0\nn3\no81\nv1\nn3\n",
9775        );
9776        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9777        let p = parse_nl_text(&nl).expect("parse o81 objective");
9778        let mut tnlp = NlTnlp::new(p);
9779        tnlp.get_nlp_info().unwrap();
9780        // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
9781        let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
9782        assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
9783        // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
9784        let mut g = [0.0_f64; 2];
9785        assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
9786        assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
9787        assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
9788    }
9789
9790    // ---- Shared-CSE constraint tape (issue #476) ----------------------
9791
9792    /// Three constraints over one `V` segment (a `.nl` *defined variable*),
9793    /// `V3 = 2*x0 + 3*x1`, referenced by all three:
9794    ///   C0: V3^2        C1: V3^3 + x2        C2: V3*x2
9795    /// `{BODY2}` is a substitution point so a variant can drop an opcode the
9796    /// hybrid path rejects into C2.
9797    const SHARED_CSE: &str = "g3 1 1 0
9798 3 3 1 0 0
9799 3 0
9800 0 0
9801 3 0 0
9802 0 0 0 1
9803 0 0 0 0 0
9804 8 3
9805 0 0
9806 0 1 0 0 0
9807V3 2 0
98080 2.0
98091 3.0
9810n0
9811C0
9812o5
9813v3
9814n2
9815C1
9816o0
9817o5
9818v3
9819n3
9820v2
9821C2
9822{BODY2}
9823O0 0
9824n0
9825r
98262 0
98272 0
98282 0
9829b
98303
98313
98323
9833k2
98343
98356
9836J0 2
98370 0
98381 0
9839J1 3
98400 0
98411 0
98422 0
9843J2 3
98440 0
98451 0
98462 0
9847G0 3
98480 1.0
98491 1.0
98502 1.0
9851";
9852
9853    fn shared_cse_nl(body2: &str) -> String {
9854        SHARED_CSE.replace("{BODY2}", body2)
9855    }
9856
9857    /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
9858    /// per-summand tapes return — it is a different traversal of the same
9859    /// DAG, not a different derivative. Forced on here regardless of
9860    /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
9861    /// the size heuristic that decides when to use it.
9862    #[test]
9863    fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
9864        let nl = shared_cse_nl("o2\nv3\nv2");
9865        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9866
9867        let mut hybrid = NlTnlp::new(p.clone());
9868        let info = hybrid.get_nlp_info().unwrap();
9869        let nnz = info.nnz_jac_g as usize;
9870        hybrid
9871            .con_hybrid
9872            .as_mut()
9873            .expect("CSE shared by 3 constraints must build the hybrid tape")
9874            .use_for_jac = true;
9875
9876        let mut flat = NlTnlp::new(p);
9877        flat.get_nlp_info().unwrap();
9878        flat.con_hybrid = None;
9879
9880        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9881            let mut jh = vec![0.0_f64; nnz];
9882            let mut jf = vec![0.0_f64; nnz];
9883            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9884            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9885            assert_eq!(
9886                jh, jf,
9887                "hybrid Jacobian differs from the flat tape at {x:?}"
9888            );
9889
9890            // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
9891            let s = 2.0 * x[0] + 3.0 * x[1];
9892            let want = [
9893                4.0 * s,
9894                6.0 * s,
9895                6.0 * s * s,
9896                9.0 * s * s,
9897                1.0,
9898                2.0 * x[2],
9899                3.0 * x[2],
9900                s,
9901            ];
9902            assert_eq!(nnz, want.len());
9903            for k in 0..nnz {
9904                assert!(
9905                    (jh[k] - want[k]).abs() < 1e-9,
9906                    "entry {k} at {x:?}: got {}, want {}",
9907                    jh[k],
9908                    want[k]
9909                );
9910            }
9911        }
9912    }
9913
9914    /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
9915    /// CSE `S = body(x0, x1)` (the caller passes the body's expression
9916    /// text, which must reference exactly `v0` and `v1`). A deep shared
9917    /// body over few local ops per row is the regime the op-ratio gates
9918    /// are meant to catch: no repository fixture has a CSE shared across
9919    /// constraints at all, so without this the gate-on paths have no
9920    /// natural coverage.
9921    fn shared_body_chain_nl(m: usize, body: &str) -> String {
9922        let n = m + 2;
9923        let nzc = 3 * m;
9924        let mut s = String::new();
9925        s.push_str("g3 1 1 0\n");
9926        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9927        s.push_str(&format!(" {m} 0\n 0 0\n"));
9928        s.push_str(&format!(" {n} 0 0\n"));
9929        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9930        s.push_str(&format!(" {nzc} {n}\n"));
9931        s.push_str(" 0 0\n 0 1 0 0 0\n");
9932        // The shared body.
9933        s.push_str(&format!("V{n} 0 0\n"));
9934        s.push_str(body);
9935        // Rows: S * x_{i+2}.
9936        for i in 0..m {
9937            s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
9938        }
9939        s.push_str("O0 0\nn0\n");
9940        s.push_str(&format!("x{n}\n"));
9941        for j in 0..n {
9942            s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
9943        }
9944        s.push_str("r\n");
9945        for _ in 0..m {
9946            s.push_str("2 0\n");
9947        }
9948        s.push_str("b\n");
9949        for _ in 0..n {
9950            s.push_str("3\n");
9951        }
9952        // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
9953        s.push_str(&format!("k{}\n", n - 1));
9954        let mut acc = 0;
9955        for j in 0..n - 1 {
9956            acc += if j < 2 { m } else { 1 };
9957            s.push_str(&format!("{acc}\n"));
9958        }
9959        for i in 0..m {
9960            s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
9961        }
9962        s.push_str(&format!("G0 {n}\n"));
9963        for j in 0..n {
9964            s.push_str(&format!("{j} 0.0\n"));
9965        }
9966        s
9967    }
9968
9969    /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
9970    /// The forward value saturates to `inf` past depth ≈ 5, which the
9971    /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
9972    /// bounded variant below instead, whose second-order terms would
9973    /// otherwise mix `inf`s of both signs into `NaN`.
9974    fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
9975        let mut body = String::new();
9976        for _ in 0..depth {
9977            body.push_str("o44\n");
9978        }
9979        body.push_str("o2\nn0.01\no0\nv0\nv1\n");
9980        shared_body_chain_nl(m, &body)
9981    }
9982
9983    /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
9984    /// (x0 + x1))` — mathematically the identity chain, so the value
9985    /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
9986    /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
9987    /// through both second-order sweeps. `2 * pairs` body ops drive the
9988    /// flat/shared op ratio as high as the Hessian gate tests need.
9989    fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
9990        let mut body = String::new();
9991        for _ in 0..pairs {
9992            body.push_str("o43\no44\n");
9993        }
9994        body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
9995        shared_body_chain_nl(m, &body)
9996    }
9997
9998    /// With a deep shared body the gate turns itself on, and the path it
9999    /// turns on must still agree with the flat tapes exactly.
10000    #[test]
10001    fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
10002        let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
10003
10004        let mut hybrid = NlTnlp::new(p.clone());
10005        let info = hybrid.get_nlp_info().unwrap();
10006        let nnz = info.nnz_jac_g as usize;
10007        assert!(
10008            hybrid
10009                .con_hybrid
10010                .as_ref()
10011                .expect("shared CSE must build the hybrid tape")
10012                .use_for_jac,
10013            "a 40-deep body shared by 16 rows is well past the op-ratio gate"
10014        );
10015
10016        let mut flat = NlTnlp::new(p);
10017        flat.get_nlp_info().unwrap();
10018        flat.con_hybrid = None;
10019
10020        for scale in [1.0_f64, -0.7, 2.5] {
10021            let x: Vec<f64> = (0..info.n as usize)
10022                .map(|j| scale * (0.2 + 0.03 * j as f64))
10023                .collect();
10024            let mut jh = vec![0.0_f64; nnz];
10025            let mut jf = vec![0.0_f64; nnz];
10026            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
10027            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
10028            assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
10029            assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
10030        }
10031    }
10032
10033    /// The Jacobian gate is off for a model whose shared bodies are small,
10034    /// because there the hybrid traversal's per-op overhead outweighs the
10035    /// halved forward sweep. `eval_g` still takes the hybrid path — that
10036    /// one is a win at any ratio.
10037    #[test]
10038    fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
10039        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10040        let mut t = NlTnlp::new(p);
10041        t.get_nlp_info().unwrap();
10042        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10043        assert!(
10044            !h.use_for_jac,
10045            "a 3-row model with a 2-term CSE is far below the op-ratio gate"
10046        );
10047    }
10048
10049    /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
10050    /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
10051    /// adjoints and running one prelude sweep reassociates floating-point
10052    /// products, so the two paths agree only to rounding on general inputs —
10053    /// but here every operation in both traversals is exact (dyadic values,
10054    /// small-integer coefficients, polynomial ops), so the results must be
10055    /// bit-identical, pinning the arithmetic itself and not just its
10056    /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
10057    /// path is covered independently of the size heuristic.
10058    #[test]
10059    fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
10060        let nl = shared_cse_nl("o2\nv3\nv2");
10061        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10062
10063        let mut hybrid = NlTnlp::new(p.clone());
10064        let info = hybrid.get_nlp_info().unwrap();
10065        let nnz = info.nnz_h_lag as usize;
10066        hybrid
10067            .con_hybrid
10068            .as_mut()
10069            .expect("CSE shared by 3 constraints must build the hybrid tape")
10070            .use_for_hess = true;
10071
10072        let mut flat = NlTnlp::new(p);
10073        flat.get_nlp_info().unwrap();
10074        flat.con_hybrid = None;
10075
10076        let pairs: Vec<(usize, usize)> = hybrid
10077            .h_irow
10078            .iter()
10079            .zip(&hybrid.h_jcol)
10080            .map(|(&i, &j)| (i as usize, j as usize))
10081            .collect();
10082
10083        // Two multiplier sets: one all-live, one with a dead row so the
10084        // λ == 0 skip is exercised on the hybrid path too.
10085        for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
10086            for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10087                let mut hh = vec![0.0_f64; nnz];
10088                let mut hf = vec![0.0_f64; nnz];
10089                assert!(hybrid.eval_h(
10090                    Some(&x),
10091                    true,
10092                    1.0,
10093                    Some(&lam),
10094                    true,
10095                    SparsityRequest::Values { values: &mut hh }
10096                ));
10097                assert!(flat.eval_h(
10098                    Some(&x),
10099                    true,
10100                    1.0,
10101                    Some(&lam),
10102                    true,
10103                    SparsityRequest::Values { values: &mut hf }
10104                ));
10105                assert_eq!(
10106                    hh, hf,
10107                    "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
10108                );
10109
10110                // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
10111                // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
10112                // objective is constant, so the Lagrangian Hessian is
10113                //   (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
10114                let s = 2.0 * x[0] + 3.0 * x[1];
10115                let q = 2.0 * lam[0] + 6.0 * s * lam[1];
10116                let dv = [2.0, 3.0, 0.0];
10117                for (k, &(i, j)) in pairs.iter().enumerate() {
10118                    let mut want = q * dv[i] * dv[j];
10119                    if i == 2 {
10120                        want += lam[2] * dv[j];
10121                    }
10122                    if j == 2 {
10123                        want += lam[2] * dv[i];
10124                    }
10125                    assert!(
10126                        (hh[k] - want).abs() < 1e-9,
10127                        "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
10128                        hh[k]
10129                    );
10130                }
10131            }
10132        }
10133    }
10134
10135    /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
10136    /// turns the Hessian gate on by itself, and the path it turns on must
10137    /// agree with the flat tapes. Not bitwise here: the shared prelude
10138    /// reverse sweep runs once over the `λ_k`-folded adjoints of all
10139    /// summands where the flat path runs per summand, and that
10140    /// reassociation moves transcendental results by rounding — so the bar
10141    /// is a relative few-ULP band, with the exact-arithmetic case pinned
10142    /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
10143    #[test]
10144    fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
10145        let m = 16;
10146        let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
10147
10148        let mut hybrid = NlTnlp::new(p.clone());
10149        let info = hybrid.get_nlp_info().unwrap();
10150        let nnz = info.nnz_h_lag as usize;
10151        assert!(
10152            hybrid
10153                .con_hybrid
10154                .as_ref()
10155                .expect("shared CSE must build the hybrid tape")
10156                .use_for_hess,
10157            "a 40-op body shared by 16 rows is well past the op-ratio gate"
10158        );
10159
10160        let mut flat = NlTnlp::new(p);
10161        flat.get_nlp_info().unwrap();
10162        flat.con_hybrid = None;
10163
10164        let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
10165        for scale in [1.0_f64, -0.7, 2.5] {
10166            let x: Vec<f64> = (0..info.n as usize)
10167                .map(|j| scale * (0.2 + 0.03 * j as f64))
10168                .collect();
10169            // Run the hybrid Jacobian first, the order a real solve
10170            // iteration uses. Its `gradient_summand` sweeps leave the
10171            // *Jacobian's* prelude adjoint arena dirty; the Hessian's
10172            // accumulators must be its own buffers with the all-zero-
10173            // between-colors invariant, or this seeds `eval_h` with a
10174            // stale row gradient (caught here).
10175            let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
10176            assert!(hybrid.eval_jac_g(
10177                Some(&x),
10178                true,
10179                SparsityRequest::Values { values: &mut jac }
10180            ));
10181            let mut hh = vec![0.0_f64; nnz];
10182            let mut hf = vec![0.0_f64; nnz];
10183            assert!(hybrid.eval_h(
10184                Some(&x),
10185                true,
10186                1.0,
10187                Some(&lam),
10188                true,
10189                SparsityRequest::Values { values: &mut hh }
10190            ));
10191            assert!(flat.eval_h(
10192                Some(&x),
10193                true,
10194                1.0,
10195                Some(&lam),
10196                true,
10197                SparsityRequest::Values { values: &mut hf }
10198            ));
10199            for k in 0..nnz {
10200                assert!(
10201                    hh[k].is_finite() && hf[k].is_finite(),
10202                    "non-finite Hessian entry {k} defeats the comparison"
10203                );
10204                let tol = 1e-12 * hf[k].abs().max(1.0);
10205                assert!(
10206                    (hh[k] - hf[k]).abs() <= tol,
10207                    "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
10208                    hh[k],
10209                    hf[k]
10210                );
10211            }
10212            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10213        }
10214    }
10215
10216    /// `.nl` text for two independent shared-CSE blocks of different widths:
10217    /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
10218    /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
10219    /// of the form `body * x_r`.
10220    ///
10221    /// The width difference is the point. A body summing `w` variables gives
10222    /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
10223    /// conflict and the coloring must spend `w` colors on them; the narrower
10224    /// block reuses the low colors. The surplus colors therefore belong to
10225    /// block A alone, and a per-color prelude walk over *both* bodies would
10226    /// be doing work for a body that color cannot reach.
10227    fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
10228        let nvars = wide + narrow;
10229        let m = 2 * rows;
10230        let n = nvars + m;
10231        // Block A rows touch `wide` body vars + 1 row var; block B rows
10232        // touch `narrow` + 1.
10233        let nzc = rows * (wide + 1) + rows * (narrow + 1);
10234        let mut s = String::new();
10235        s.push_str("g3 1 1 0\n");
10236        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
10237        s.push_str(&format!(" {m} 0\n 0 0\n"));
10238        s.push_str(&format!(" {n} 0 0\n"));
10239        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
10240        s.push_str(&format!(" {nzc} {n}\n"));
10241        s.push_str(" 0 0\n 0 2 0 0 0\n");
10242        // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
10243        // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
10244        for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
10245            s.push_str(&format!("V{} 0 0\n", n + b));
10246            s.push_str("o41\n");
10247            for _ in 0..count - 1 {
10248                s.push_str("o0\n");
10249            }
10250            for j in 0..*count {
10251                s.push_str(&format!("v{}\n", base + j));
10252            }
10253        }
10254        // Rows: body_b * x_{rowvar}.
10255        for i in 0..m {
10256            let b = i / rows;
10257            s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
10258        }
10259        s.push_str("O0 0\nn0\n");
10260        s.push_str(&format!("x{n}\n"));
10261        for j in 0..n {
10262            s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
10263        }
10264        s.push_str("r\n");
10265        for _ in 0..m {
10266            s.push_str("2 0\n");
10267        }
10268        s.push_str("b\n");
10269        for _ in 0..n {
10270            s.push_str("3\n");
10271        }
10272        // Cumulative Jacobian column counts for the first n-1 columns.
10273        s.push_str(&format!("k{}\n", n - 1));
10274        let mut acc = 0;
10275        for j in 0..n - 1 {
10276            acc += if j < nvars { rows } else { 1 };
10277            s.push_str(&format!("{acc}\n"));
10278        }
10279        for i in 0..m {
10280            let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
10281            s.push_str(&format!("J{i} {}\n", count + 1));
10282            let mut cols: Vec<usize> = (base..base + count).collect();
10283            cols.push(nvars + i);
10284            cols.sort_unstable();
10285            for c in cols {
10286                s.push_str(&format!("{c} 0.0\n"));
10287            }
10288        }
10289        s.push_str(&format!("G0 {n}\n"));
10290        for j in 0..n {
10291            s.push_str(&format!("{j} 0.0\n"));
10292        }
10293        s
10294    }
10295
10296    /// Both prelude sweeps run once per color, so iterating the whole prelude
10297    /// each time would cost `n_colors × |prelude|` where the op-ratio gate
10298    /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
10299    /// `eval_h` instead walks the union of that color's summands'
10300    /// `prelude_reach`.
10301    ///
10302    /// What this can and cannot pin is worth being exact about, because the
10303    /// change is pure performance: walking the whole prelude per color
10304    /// computes the *same* Hessian, so no assertion on output values can
10305    /// detect it, and a timing assertion would be flaky. So this asserts the
10306    /// two things that are checkable — that the table the sweeps iterate is
10307    /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
10308    /// where colors genuinely reach different bodies, and that each reach list
10309    /// is ascending and operand-closed, the invariants that make the narrowed
10310    /// walk safe — plus agreement with the flat tapes under narrowing.
10311    #[test]
10312    fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
10313        let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
10314        let mut hybrid = NlTnlp::new(p.clone());
10315        let info = hybrid.get_nlp_info().unwrap();
10316        let nnz = info.nnz_h_lag as usize;
10317        let m = info.m as usize;
10318
10319        {
10320            let h = hybrid
10321                .con_hybrid
10322                .as_mut()
10323                .expect("two shared CSE bodies must build the hybrid tape");
10324            h.use_for_hess = true;
10325
10326            let np = h.tape.n_prelude_ops();
10327            let n_colors = h.hess_color_reach_off.len() - 1;
10328            let total: usize = h.hess_color_reach.len();
10329            assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
10330            assert!(
10331                total < n_colors * np,
10332                "per-color reach must be strictly smaller than walking the whole \
10333                 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
10334                n_colors * np
10335            );
10336            // Every reach list must be ascending and operand-closed, which is
10337            // what makes the narrowed walk safe.
10338            for c in 0..n_colors {
10339                let r =
10340                    &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
10341                assert!(
10342                    r.windows(2).all(|w| w[0] < w[1]),
10343                    "color {c} reach is not strictly ascending"
10344                );
10345                let member: std::collections::HashSet<u32> = r.iter().copied().collect();
10346                for &i in r {
10347                    let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
10348                    for opnd in [a, b].into_iter().flatten() {
10349                        assert!(
10350                            member.contains(&(opnd as u32)),
10351                            "color {c}: slot {i}'s operand {opnd} is missing from its reach"
10352                        );
10353                    }
10354                }
10355            }
10356        }
10357
10358        let mut flat = NlTnlp::new(p);
10359        flat.get_nlp_info().unwrap();
10360        flat.con_hybrid = None;
10361
10362        let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
10363        for scale in [1.0_f64, -0.6] {
10364            let x: Vec<f64> = (0..info.n as usize)
10365                .map(|j| scale * (0.15 + 0.02 * j as f64))
10366                .collect();
10367            let mut hh = vec![0.0_f64; nnz];
10368            let mut hf = vec![0.0_f64; nnz];
10369            assert!(hybrid.eval_h(
10370                Some(&x),
10371                true,
10372                1.0,
10373                Some(&lam),
10374                true,
10375                SparsityRequest::Values { values: &mut hh }
10376            ));
10377            assert!(flat.eval_h(
10378                Some(&x),
10379                true,
10380                1.0,
10381                Some(&lam),
10382                true,
10383                SparsityRequest::Values { values: &mut hf }
10384            ));
10385            for k in 0..nnz {
10386                let tol = 1e-12 * hf[k].abs().max(1.0);
10387                assert!(
10388                    (hh[k] - hf[k]).abs() <= tol,
10389                    "narrowed-reach Hessian entry {k} at scale {scale}: \
10390                     hybrid {} vs flat {}",
10391                    hh[k],
10392                    hf[k]
10393                );
10394            }
10395            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10396        }
10397    }
10398
10399    /// The Hessian gate is off for a model whose shared bodies are small —
10400    /// below the ratio where the shared prelude sweeps pay for the hybrid
10401    /// traversal's per-op overhead — leaving `eval_h` on the flat path
10402    /// (which the gate keeps bit-identical for such models by definition).
10403    #[test]
10404    fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
10405        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10406        let mut t = NlTnlp::new(p);
10407        t.get_nlp_info().unwrap();
10408        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10409        assert!(
10410            !h.use_for_hess,
10411            "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
10412        );
10413    }
10414
10415    /// A CSE referenced from several constraints is evaluated once per
10416    /// `eval_g` via the shared prelude instead of once per reference. The
10417    /// values must be bit-identical to the flat per-summand `Tape` path,
10418    /// which is what makes the optimization safe to apply unconditionally.
10419    #[test]
10420    fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
10421        let nl = shared_cse_nl("o2\nv3\nv2");
10422        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10423        let mut hybrid = NlTnlp::new(p.clone());
10424        hybrid.get_nlp_info().unwrap();
10425        let h = hybrid
10426            .con_hybrid
10427            .as_ref()
10428            .expect("CSE shared by 3 constraints must take the hybrid path");
10429        assert!(
10430            h.tape.n_prelude_ops() > 0,
10431            "shared CSE body must land in the prelude"
10432        );
10433
10434        // Same model with the hybrid path switched off: the reference.
10435        let mut flat = NlTnlp::new(p);
10436        flat.get_nlp_info().unwrap();
10437        flat.con_hybrid = None;
10438
10439        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10440            let mut gh = [0.0_f64; 3];
10441            let mut gf = [0.0_f64; 3];
10442            assert!(hybrid.eval_g(&x, true, &mut gh));
10443            assert!(flat.eval_g(&x, true, &mut gf));
10444            // V3 = 2 x0 + 3 x1.
10445            let s = 2.0 * x[0] + 3.0 * x[1];
10446            let want = [s * s, s * s * s + x[2], s * x[2]];
10447            for i in 0..3 {
10448                assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
10449                assert!(
10450                    (gh[i] - want[i]).abs() < 1e-9,
10451                    "row {i}: got {}, want {}",
10452                    gh[i],
10453                    want[i]
10454                );
10455            }
10456        }
10457    }
10458
10459    /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
10460    /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
10461    /// take that path after `hybrid_supported` clears the model. Here a
10462    /// min-list in one constraint has to disable it for the whole block —
10463    /// falling back, not panicking.
10464    #[test]
10465    fn unsupported_opcode_falls_back_to_the_flat_tape() {
10466        let nl = shared_cse_nl("o11\n2\nv3\nv2");
10467        let p = parse_nl_text(&nl).expect("parse min-list model");
10468        let mut tnlp = NlTnlp::new(p);
10469        tnlp.get_nlp_info().unwrap();
10470        assert!(
10471            tnlp.con_hybrid.is_none(),
10472            "a min-list anywhere in the constraint block must disable the hybrid path"
10473        );
10474        let mut g = [0.0_f64; 3];
10475        assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
10476        let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
10477        assert!((g[0] - s * s).abs() < 1e-9);
10478        assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
10479    }
10480
10481    // ---- In-memory construction + HVP (issue #469) --------------------
10482
10483    fn v(i: usize) -> Expr {
10484        Expr::Var(i)
10485    }
10486
10487    fn c(x: Number) -> Expr {
10488        Expr::Const(x)
10489    }
10490
10491    fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
10492        Expr::Binary(op, Box::new(a), Box::new(b))
10493    }
10494
10495    fn un(op: UnaryOp, a: Expr) -> Expr {
10496        Expr::Unary(op, Box::new(a))
10497    }
10498
10499    /// `NlProblemParts` for an `n`-variable, unbounded model.
10500    fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
10501        let m = constraints.len();
10502        NlProblemParts {
10503            minimize: true,
10504            objective,
10505            obj_constant: 0.0,
10506            constraints,
10507            x_l: vec![-1e19; n],
10508            x_u: vec![1e19; n],
10509            x0: vec![0.0; n],
10510            g_l: vec![-1e19; m],
10511            g_u: vec![1e19; m],
10512            var_names: Vec::new(),
10513            con_names: Vec::new(),
10514        }
10515    }
10516
10517    /// A model built from expressions evaluates exactly like a parsed one:
10518    /// objective, gradient, constraints, and Jacobian all come from the
10519    /// same tape, with no `.nl` text in the loop.
10520    #[test]
10521    fn from_expressions_builds_evaluable_problem() {
10522        // min (1-x0)^2 + 100*(x1 - x0^2)^2   s.t.  x0^2 + x1^2 <= 2
10523        let rosen = bin(
10524            BinOp::Add,
10525            bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
10526            bin(
10527                BinOp::Mul,
10528                c(100.0),
10529                bin(
10530                    BinOp::Pow,
10531                    bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
10532                    c(2.0),
10533                ),
10534            ),
10535        );
10536        let circle = bin(
10537            BinOp::Add,
10538            bin(BinOp::Pow, v(0), c(2.0)),
10539            bin(BinOp::Pow, v(1), c(2.0)),
10540        );
10541
10542        let mut p = parts(2, rosen, vec![circle]);
10543        p.g_l = vec![0.0];
10544        p.g_u = vec![2.0];
10545        p.x0 = vec![-1.2, 1.0];
10546        p.var_names = names(&["x", "y"]);
10547        p.con_names = names(&["circle"]);
10548
10549        let prob = NlProblem::from_expressions(p).expect("build");
10550        assert_eq!((prob.n, prob.m), (2, 1));
10551        assert_eq!(prob.var_names, names(&["x", "y"]));
10552
10553        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10554        t.get_nlp_info().unwrap();
10555
10556        // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
10557        let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
10558        assert!((f - 24.2).abs() < 1e-10, "f = {f}");
10559
10560        // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
10561        //    = (4.4 + 480*(-0.44)... ) — computed below rather than
10562        //      transcribed, so the check is the formula, not an editor.
10563        let (x0, x1) = (-1.2, 1.0);
10564        let want = [
10565            -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
10566            200.0 * (x1 - x0 * x0),
10567        ];
10568        let mut g = [0.0_f64; 2];
10569        assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
10570        for j in 0..2 {
10571            assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
10572        }
10573
10574        // g(x) = x0^2 + x1^2 = 2.44
10575        let mut gv = [0.0_f64; 1];
10576        assert!(t.eval_g(&[x0, x1], true, &mut gv));
10577        assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
10578    }
10579
10580    /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
10581    /// this path. None of the three survives a `.nl` round trip in a
10582    /// typical frontend — `atan2` has no two-argument funcall path,
10583    /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
10584    /// at all — which is the reason the in-memory door exists.
10585    #[test]
10586    fn from_expressions_carries_ops_nl_cannot_express() {
10587        let obj = Expr::Sum(vec![
10588            bin(BinOp::Atan2, v(0), v(1)),
10589            Expr::MinList(vec![v(0), v(1)]),
10590            Expr::MaxList(vec![v(0), v(1)]),
10591            un(UnaryOp::Erf, v(0)),
10592        ]);
10593        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10594        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10595        t.get_nlp_info().unwrap();
10596
10597        let x: [Number; 2] = [0.8, 1.5];
10598        // atan2 + min + max + erf; min+max == x0+x1 for any pair.
10599        let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
10600        let f = t.eval_f(&x, true).unwrap();
10601        assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
10602    }
10603
10604    /// A `Var` index past `n` would be an out-of-bounds read in the
10605    /// tape's forward sweep. It has to be caught at construction, while
10606    /// it is still a diagnosable user error.
10607    #[test]
10608    fn from_expressions_rejects_out_of_range_var() {
10609        let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
10610            .expect_err("Var(5) with n = 2 must be rejected");
10611        assert!(err.contains("Var(5)"), "{err}");
10612
10613        let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
10614            .expect_err("constraint Var(2) with n = 2 must be rejected");
10615        assert!(err.contains("constraint 0"), "{err}");
10616
10617        // Length mismatches are errors too, not panics.
10618        let mut p = parts(2, c(0.0), Vec::new());
10619        p.x0 = vec![0.0; 3];
10620        let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
10621        assert!(err.contains("x0"), "{err}");
10622    }
10623
10624    /// An out-of-range `Var` must be caught wherever it hides, not just at
10625    /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
10626    /// branch, and a funcall argument all reach the same forward sweep.
10627    #[test]
10628    fn from_expressions_finds_out_of_range_vars_in_every_position() {
10629        let inner_cse = Arc::new(v(7));
10630        let cases: Vec<(&str, Expr)> = vec![
10631            ("bare", v(7)),
10632            ("cse", Expr::Cse(Arc::new(v(7)))),
10633            ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
10634            (
10635                "cond branch",
10636                Expr::Cond {
10637                    cond: Box::new(c(1.0)),
10638                    then_: Box::new(v(7)),
10639                    else_: Box::new(c(0.0)),
10640                },
10641            ),
10642            ("min list", Expr::MinList(vec![c(0.0), v(7)])),
10643            (
10644                "sum",
10645                Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
10646            ),
10647        ];
10648        for (label, e) in cases {
10649            let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
10650                .err()
10651                .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
10652            assert!(err.contains("Var(7)"), "{label}: {err}");
10653        }
10654    }
10655
10656    /// A balanced share-DAG: each level is one `Cse` whose body
10657    /// references the level below twice. Depth `d` is `d` distinct nodes
10658    /// but `2^d` paths, so any walk that re-enters a shared body per
10659    /// occurrence is Θ(2^d).
10660    fn share_dag(depth: usize) -> Expr {
10661        let mut e = v(0);
10662        for _ in 0..depth {
10663            let shared = Arc::new(e);
10664            e = bin(
10665                BinOp::Add,
10666                Expr::Cse(Arc::clone(&shared)),
10667                Expr::Cse(shared),
10668            );
10669        }
10670        e
10671    }
10672
10673    /// The walks over a shared DAG must be memoized, not exponential.
10674    ///
10675    /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
10676    /// does not "fail" so much as never finish, which is exactly the
10677    /// signal. `from_expressions` is the door that makes such a DAG
10678    /// trivially constructible, but the blowup was reachable through
10679    /// `collect_vars` (which presolve calls on every solve, via
10680    /// `get_variables_linearity`) and `collect_funcall_ids` (which
10681    /// `NlTnlp::try_new` runs over every row).
10682    #[test]
10683    fn shared_dag_walks_are_memoized_not_exponential() {
10684        const DEPTH: usize = 30;
10685        let e = share_dag(DEPTH);
10686
10687        let mut vars = BTreeSet::new();
10688        collect_vars(&e, &mut vars);
10689        assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
10690
10691        let mut ids = BTreeSet::new();
10692        super::super::nl_external::collect_funcall_ids(&e, &mut ids);
10693        assert!(ids.is_empty());
10694
10695        // The whole build path, end to end: validation, tape construction,
10696        // and the linearity metadata presolve consumes.
10697        let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
10698        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10699        t.get_nlp_info().unwrap();
10700        let mut lin = vec![Linearity::Linear; 1];
10701        assert!(t.get_variables_linearity(&mut lin));
10702    }
10703
10704    /// `from_expressions` cannot carry AMPL imported functions — there is
10705    /// nowhere to put the `F`-segment declarations that bind a funcall id
10706    /// to a library — so a `Funcall` must be refused up front. Accepting it
10707    /// produces "AMPLFUNC is not set", which the user cannot act on:
10708    /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
10709    #[test]
10710    fn from_expressions_rejects_imported_function_calls() {
10711        let call = Expr::Funcall {
10712            id: 0,
10713            args: vec![FuncallArg::Real(v(0))],
10714        };
10715        let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
10716            .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
10717        assert!(err.contains("imported function"), "{err}");
10718        assert!(
10719            err.contains("read_nl") || err.contains("parse_nl_text"),
10720            "the error must point at the paths that do support externals: {err}"
10721        );
10722
10723        // Also when buried in a constraint, behind a Cse.
10724        let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
10725        let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
10726            .expect_err("a buried Funcall must be rejected too");
10727        assert!(err.contains("constraint 0"), "{err}");
10728    }
10729
10730    /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
10731    /// same tapes, same weights, one seed instead of a color sweep. The
10732    /// objective and both constraints are chosen with cross terms so the
10733    /// off-diagonal blocks actually carry signal.
10734    #[test]
10735    fn hessian_vector_product_matches_dense_hessian() {
10736        let obj = Expr::Sum(vec![
10737            bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
10738            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10739            un(UnaryOp::Erf, v(2)),
10740        ]);
10741        let cons = vec![
10742            bin(
10743                BinOp::Add,
10744                bin(BinOp::Pow, v(0), c(2.0)),
10745                un(UnaryOp::Sin, v(2)),
10746            ),
10747            bin(BinOp::Mul, v(1), v(2)),
10748        ];
10749        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10750        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10751        let info = t.get_nlp_info().unwrap();
10752
10753        let x = [0.3, -0.7, 1.1];
10754        let lam = [0.5, -1.25];
10755        let obj_factor = 2.0;
10756
10757        // Dense Hessian from the sparse lower triangle.
10758        let nnz = info.nnz_h_lag as usize;
10759        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10760        assert!(t.eval_h(
10761            None,
10762            false,
10763            1.0,
10764            None,
10765            false,
10766            SparsityRequest::Structure {
10767                irow: &mut irow,
10768                jcol: &mut jcol
10769            }
10770        ));
10771        let mut hvals = vec![0.0_f64; nnz];
10772        assert!(t.eval_h(
10773            Some(&x),
10774            true,
10775            obj_factor,
10776            Some(&lam),
10777            true,
10778            SparsityRequest::Values { values: &mut hvals }
10779        ));
10780        let mut dense = [[0.0_f64; 3]; 3];
10781        for k in 0..nnz {
10782            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10783            dense[i][j] += hvals[k];
10784            if i != j {
10785                dense[j][i] += hvals[k];
10786            }
10787        }
10788
10789        // Each unit seed recovers a column; a mixed seed catches an HVP
10790        // that only happens to be right on the basis vectors.
10791        let seeds: [[Number; 3]; 4] = [
10792            [1.0, 0.0, 0.0],
10793            [0.0, 1.0, 0.0],
10794            [0.0, 0.0, 1.0],
10795            [0.4, -1.3, 2.0],
10796        ];
10797        let mut out = vec![0.0; 3];
10798        for s in &seeds {
10799            t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
10800                .expect("hvp");
10801            for i in 0..3 {
10802                let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
10803                assert!(
10804                    (out[i] - want).abs() < 1e-9,
10805                    "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
10806                    out[i]
10807                );
10808            }
10809        }
10810    }
10811
10812    /// `lam = None` is the objective block alone, and `out` is
10813    /// overwritten (not accumulated) so a reused buffer is safe.
10814    #[test]
10815    fn hessian_vector_product_defaults_and_validation() {
10816        // f = x0^2 + 3 x0 x1  ->  ∇²f = [[2, 3], [3, 0]]
10817        let obj = bin(
10818            BinOp::Add,
10819            bin(BinOp::Pow, v(0), c(2.0)),
10820            bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
10821        );
10822        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10823        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10824        t.get_nlp_info().unwrap();
10825
10826        let mut out = vec![7.0, -7.0]; // dirty buffer
10827        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
10828            .expect("hvp");
10829        assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
10830        assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
10831
10832        // obj_factor scales linearly.
10833        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
10834            .expect("hvp");
10835        assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
10836
10837        // Length mismatches are errors, not panics or silent truncation.
10838        let mut short = vec![0.0; 1];
10839        assert!(
10840            t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
10841                .is_err()
10842        );
10843        assert!(
10844            t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
10845                .is_err()
10846        );
10847        assert!(
10848            t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
10849                .is_err()
10850        );
10851    }
10852
10853    /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
10854    /// Hessian — the sparse shape an IPM actually meets. The block HVP has
10855    /// to reproduce it column for column, including the structural zeros:
10856    /// a bug that leaked coupling between non-adjacent variables would
10857    /// show up here and nowhere in a small dense test.
10858    #[test]
10859    fn hessian_vector_products_on_a_sparse_hessian() {
10860        const N: usize = 8;
10861        let mut terms = Vec::new();
10862        for i in 0..N - 1 {
10863            terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
10864        }
10865        for i in 0..N {
10866            terms.push(un(UnaryOp::Exp, v(i)));
10867        }
10868        let prob =
10869            NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
10870        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10871        let info = t.get_nlp_info().unwrap();
10872
10873        // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
10874        assert_eq!(
10875            info.nnz_h_lag as usize,
10876            2 * N - 1,
10877            "chain objective should give a tridiagonal Hessian, not a dense one"
10878        );
10879
10880        let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
10881
10882        // Densify the sparse triangle for the reference.
10883        let nnz = info.nnz_h_lag as usize;
10884        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10885        assert!(t.eval_h(
10886            None,
10887            false,
10888            1.0,
10889            None,
10890            false,
10891            SparsityRequest::Structure {
10892                irow: &mut irow,
10893                jcol: &mut jcol
10894            }
10895        ));
10896        let mut hvals = vec![0.0; nnz];
10897        assert!(t.eval_h(
10898            Some(&x),
10899            true,
10900            1.0,
10901            None,
10902            true,
10903            SparsityRequest::Values { values: &mut hvals }
10904        ));
10905        let mut dense = vec![vec![0.0; N]; N];
10906        for k in 0..nnz {
10907            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10908            dense[i][j] += hvals[k];
10909            if i != j {
10910                dense[j][i] += hvals[k];
10911            }
10912        }
10913
10914        // One block of N unit seeds recovers the whole matrix — the
10915        // "densify via HVPs" path, in one call.
10916        let mut seeds = vec![0.0; N * N];
10917        for cc in 0..N {
10918            seeds[cc * N + cc] = 1.0;
10919        }
10920        let mut out = vec![0.0; N * N];
10921        t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
10922            .expect("block hvp");
10923        for cc in 0..N {
10924            for i in 0..N {
10925                assert!(
10926                    (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
10927                    "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
10928                    out[cc * N + i],
10929                    dense[i][cc]
10930                );
10931            }
10932        }
10933    }
10934
10935    /// The block form must agree with `k` separate single-vector calls
10936    /// (it shares one forward sweep across directions, so a bug there
10937    /// would show as a per-direction discrepancy), and must skip an
10938    /// all-zero direction without disturbing its neighbours.
10939    #[test]
10940    fn hessian_vector_products_match_repeated_single_calls() {
10941        let obj = Expr::Sum(vec![
10942            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10943            bin(BinOp::Pow, v(2), c(4.0)),
10944            bin(BinOp::Mul, v(0), v(2)),
10945        ]);
10946        let cons = vec![bin(BinOp::Mul, v(1), v(2))];
10947        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10948        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10949        t.get_nlp_info().unwrap();
10950
10951        let x = [0.4, -0.6, 1.3];
10952        let lam = [0.75];
10953        let cols: [[Number; 3]; 4] = [
10954            [1.0, 2.0, -3.0],
10955            [0.0, 0.0, 0.0], // the skipped direction
10956            [0.5, 0.0, 0.0],
10957            [-1.0, 1.0, 1.0],
10958        ];
10959
10960        let mut block = vec![0.0; 3 * cols.len()];
10961        let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
10962        t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
10963            .expect("block hvp");
10964
10965        for (c, col) in cols.iter().enumerate() {
10966            let mut single = vec![0.0; 3];
10967            t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
10968                .expect("single hvp");
10969            for i in 0..3 {
10970                assert!(
10971                    (block[c * 3 + i] - single[i]).abs() < 1e-12,
10972                    "direction {c} row {i}: block={:.12e} single={:.12e}",
10973                    block[c * 3 + i],
10974                    single[i]
10975                );
10976            }
10977        }
10978        // The zero direction really is zero, not stale scratch.
10979        assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
10980    }
10981
10982    /// `k = 0` is a legal empty block, and the length checks scale with
10983    /// `k` rather than assuming a single direction.
10984    #[test]
10985    fn hessian_vector_products_validate_block_shape() {
10986        let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
10987            .expect("build");
10988        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10989        t.get_nlp_info().unwrap();
10990
10991        let mut empty: Vec<Number> = Vec::new();
10992        assert!(
10993            t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
10994                .is_ok()
10995        );
10996
10997        // v sized for one direction while k says two.
10998        let mut out = vec![0.0; 4];
10999        assert!(
11000            t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
11001                .is_err()
11002        );
11003        // out sized for one direction while k says two.
11004        let mut short = vec![0.0; 2];
11005        assert!(
11006            t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
11007                .is_err()
11008        );
11009    }
11010
11011    /// A `maximize` model's objective is negated by the evaluator, and
11012    /// the HVP has to agree with `eval_h` about that — otherwise a
11013    /// Hessian-free step would climb where the sparse path descends.
11014    #[test]
11015    fn hessian_vector_product_respects_maximize_sign() {
11016        let obj = bin(BinOp::Pow, v(0), c(2.0));
11017        let mut p = parts(1, obj, Vec::new());
11018        p.minimize = false;
11019        let prob = NlProblem::from_expressions(p).expect("build");
11020        let mut t = NlTnlp::try_new(prob).expect("tnlp");
11021        t.get_nlp_info().unwrap();
11022
11023        // max x0^2 is minimized as -x0^2, so ∇² = -2.
11024        let mut out = vec![0.0; 1];
11025        t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
11026            .expect("hvp");
11027        assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
11028    }
11029
11030    // ---- gh#1042 R3: `|e|^p · |e|^q` merged at parse time ----------------
11031
11032    /// An unconstrained `.nl` model over `n` variables whose objective is
11033    /// the given expression-token body (one token per line).
11034    fn abs_pow_nl(n: usize, body: &[&str]) -> String {
11035        let mut s = format!(
11036            "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"
11037        );
11038        for t in body {
11039            s.push_str(t);
11040            s.push('\n');
11041        }
11042        s.push_str("b\n");
11043        for _ in 0..n {
11044            s.push_str("3\n");
11045        }
11046        s
11047    }
11048
11049    /// `(3·|x|^2)·|x|^0.85` as Vanderbei's `dallass` writes its head-loss
11050    /// terms. Read as written, the tape's product rule forms `0·∞` at
11051    /// `x = 0` and the Hessian is NaN there (measured; the gradient reads
11052    /// `0`), which fails the
11053    /// KKT factorization and forces a restoration entry (11 of `dallass`'s
11054    /// 12 on 0.13.0). Merged into `3·|x|^2.85` every value is unchanged and
11055    /// the derivatives at the kink are the finite limits, `0` and `0`.
11056    #[test]
11057    fn abs_pow_product_is_merged_and_its_derivatives_are_finite_at_the_kink() {
11058        let txt = abs_pow_nl(
11059            1,
11060            &[
11061                "o2", "o2", "n3", "o5", "o15", "v0", "n2", "o5", "o15", "v0", "n0.85",
11062            ],
11063        );
11064        let p = parse_nl_text(&txt).expect("parse");
11065        // The tree is `Mul(3, Pow(Abs(x0), 2.85))`.
11066        match &*p.obj_expr() {
11067            Expr::Binary(BinOp::Mul, k, f) => {
11068                assert!(matches!(**k, Expr::Const(c) if c == 3.0), "{k:?}");
11069                assert!(
11070                    matches!(abs_pow_parts(f), Some((Expr::Var(0), q)) if q == 2.85),
11071                    "{f:?}"
11072                );
11073            }
11074            other => panic!("not merged: {other:?}"),
11075        }
11076
11077        let mut t = NlTnlp::new(p);
11078        let info = t.get_nlp_info().unwrap();
11079        assert_eq!(info.nnz_h_lag, 1);
11080        let f = |t: &mut NlTnlp, x: f64| t.eval_f(&[x], true).unwrap();
11081        let g = |t: &mut NlTnlp, x: f64| {
11082            let mut g = [0.0];
11083            assert!(t.eval_grad_f(&[x], true, &mut g));
11084            g[0]
11085        };
11086        let h = |t: &mut NlTnlp, x: f64| {
11087            let mut v = [0.0];
11088            assert!(t.eval_h(
11089                Some(&[x]),
11090                true,
11091                1.0,
11092                None,
11093                true,
11094                SparsityRequest::Values { values: &mut v }
11095            ));
11096            v[0]
11097        };
11098        let (pw, k) = (2.85_f64, 3.0_f64);
11099        for &x in &[-2.0, -0.7, -1e-3, 0.0, 1e-3, 0.7, 2.0, 1e3] {
11100            let a = f64::abs(x);
11101            // The value is the product's: `k·|x|^2·|x|^0.85`.
11102            let want_f = k * a.powi(2) * a.powf(0.85);
11103            let want_g = k * pw * a.powf(pw - 1.0) * x.signum() * f64::from(u8::from(x != 0.0));
11104            let want_h = k * pw * (pw - 1.0) * a.powf(pw - 2.0);
11105            let (gf, gg, gh) = (f(&mut t, x), g(&mut t, x), h(&mut t, x));
11106            assert!(
11107                gf.is_finite() && gg.is_finite() && gh.is_finite(),
11108                "x={x}: {gf} {gg} {gh}"
11109            );
11110            let tol = |w: f64| 1e-13 * w.abs().max(1e-300);
11111            assert!(
11112                (gf - want_f).abs() <= tol(want_f),
11113                "f({x}) = {gf}, want {want_f}"
11114            );
11115            assert!(
11116                (gg - want_g).abs() <= tol(want_g),
11117                "g({x}) = {gg}, want {want_g}"
11118            );
11119            assert!(
11120                (gh - want_h).abs() <= tol(want_h),
11121                "h({x}) = {gh}, want {want_h}"
11122            );
11123        }
11124        // At the kink: exactly zero, not NaN.
11125        assert_eq!(
11126            (f(&mut t, 0.0), g(&mut t, 0.0), h(&mut t, 0.0)),
11127            (0.0, 0.0, 0.0)
11128        );
11129        // The Hessian matches a central difference of the gradient on both
11130        // sides of the kink.
11131        for &x in &[-1e-3, 1e-3] {
11132            let e = 1e-8;
11133            let fd = (g(&mut t, x + e) - g(&mut t, x - e)) / (2.0 * e);
11134            let hx = h(&mut t, x);
11135            assert!(
11136                (fd - hx).abs() <= 1e-6 * hx.abs(),
11137                "x={x}: fd {fd} vs h {hx}"
11138            );
11139        }
11140    }
11141
11142    /// Every near miss keeps the tree it always had: `Mul` of the two
11143    /// operands exactly as read. Different operands, a non-positive power, a
11144    /// bare (signed) base and a variable exponent are each a case where the
11145    /// merge would change a value, not only a derivative, or is not known to
11146    /// be the same function.
11147    #[test]
11148    fn abs_pow_near_misses_build_the_unmerged_tree() {
11149        let cases: [(&[&str], &[&str]); 5] = [
11150            // different operands
11151            (&["o5", "o15", "v0", "n2"], &["o5", "o15", "v1", "n0.85"]),
11152            // a negative power: `|0|^-1·|0|^2` is NaN, `|0|^1` is not
11153            (&["o5", "o15", "v0", "n-1"], &["o5", "o15", "v0", "n2"]),
11154            // a zero power
11155            (&["o5", "o15", "v0", "n0"], &["o5", "o15", "v0", "n2"]),
11156            // no abs: `x^0.5·x^0.5` is NaN for x < 0, `x^1` is not
11157            (&["o5", "v0", "n0.5"], &["o5", "v0", "n0.5"]),
11158            // a variable exponent
11159            (&["o5", "o15", "v0", "v1"], &["o5", "o15", "v0", "n2"]),
11160        ];
11161        for (a, b) in cases {
11162            let mut body = vec!["o2"];
11163            body.extend_from_slice(a);
11164            body.extend_from_slice(b);
11165            let p = parse_nl_text(&abs_pow_nl(2, &body)).expect("parse");
11166            let frag = |toks: &[&str]| {
11167                let txt: String = toks.iter().map(|t| format!("{t}\n")).collect();
11168                parse_body_fragment(&txt, 2, &[]).expect("fragment")
11169            };
11170            let want = Expr::Binary(BinOp::Mul, Box::new(frag(a)), Box::new(frag(b)));
11171            assert_eq!(
11172                format!("{:?}", p.obj_expr()),
11173                format!("{want:?}"),
11174                "near miss {body:?} was rewritten"
11175            );
11176        }
11177    }
11178
11179    /// The merge reaches into one `Mul` level on either side and keeps the
11180    /// remaining factors, so `|x|^2·(|x|^0.85·y)` and `(|x|^0.5·y)·|x|^1.5`
11181    /// become `|x|^2.85·y` and `|x|^2·y` — the latter lowered to a mul chain
11182    /// by the tape — with values unchanged.
11183    #[test]
11184    fn abs_pow_merge_keeps_the_other_factors() {
11185        for (body, pw) in [
11186            (
11187                &[
11188                    "o2", "o5", "o15", "v0", "n2", "o2", "o5", "o15", "v0", "n0.85", "v1",
11189                ][..],
11190                2.85,
11191            ),
11192            (
11193                &[
11194                    "o2", "o2", "o5", "o15", "v0", "n0.5", "v1", "o5", "o15", "v0", "n1.5",
11195                ][..],
11196                2.0,
11197            ),
11198        ] {
11199            let p = parse_nl_text(&abs_pow_nl(2, body)).expect("parse");
11200            match &*p.obj_expr() {
11201                Expr::Binary(BinOp::Mul, l, r) => {
11202                    assert!(
11203                        matches!(abs_pow_parts(l), Some((Expr::Var(0), q)) if q == pw),
11204                        "{l:?}"
11205                    );
11206                    assert!(matches!(**r, Expr::Var(1)), "{r:?}");
11207                }
11208                other => panic!("not merged: {other:?}"),
11209            }
11210            let mut t = NlTnlp::new(p);
11211            for &(x, y) in &[(-1.3, 0.4), (0.0, 2.0), (2.5, -1.0)] {
11212                let want = f64::abs(x).powf(pw) * y;
11213                let got = t.eval_f(&[x, y], true).unwrap();
11214                assert!(
11215                    (got - want).abs() <= 1e-13 * want.abs(),
11216                    "{body:?} at ({x},{y}): {got} vs {want}"
11217                );
11218            }
11219        }
11220    }
11221}