Skip to main content

pounce_nl/
nl_reader.rs

1//! Minimal AMPL `.nl` ASCII-format reader.
2//!
3//! Implements the `g`-header text dialect for problems whose constraint
4//! and objective expressions are restricted to a polynomial-friendly
5//! subset of opcodes. This is **not** a full `.nl` reader — it is the
6//! smallest piece that lets `pounce --nl-file foo.nl` solve a real
7//! AMPL-emitted unconstrained problem.
8//!
9//! Supported:
10//! * Text header (`g…`).
11//! * Constraint and objective expression segments using opcodes
12//!   `o0` (add), `o1` (sub), `o2` (mul), `o3` (div), `o5` (pow),
13//!   `o16` (unary minus), `o39` (sqrt), `o42` (log10), `o43` (log),
14//!   `o44` (exp), `o15` (abs), `o41` (sin), `o46` (cos), `o38` (tan),
15//!   `o49` (atan), `o53` (acos), plus
16//!   `n<num>` constants and `v<idx>` variables.
17//! * Linear-Jacobian (`J`) and linear-objective (`G`) segments.
18//! * Variable bounds (`b`) and constraint bounds (`r`).
19//! * Optional initial primal (`x`) segment and initial dual (`d`)
20//!   segment. Both are parsed (into `x0` / `lambda0`) and returned by
21//!   `get_starting_point`; the duals feed a `warm_start_init_point` solve.
22//! * Multiple objectives (we use only the first; per AMPL convention).
23//!
24//! Not supported (will return an error explaining what's missing):
25//! * Network / piecewise-linear constructs.
26//! * Complementarity rows.
27//! * Binary-format `.nl` files (`b…` header).
28//!
29//! References:
30//! * <https://ampl.com/REFS/hooking2.pdf> — "Hooking Your Solver to
31//!   AMPL" (David M. Gay), the canonical `.nl` spec.
32//! * `ref/Ipopt/test/mytoy.nl` — annotated example used for the unit
33//!   tests in this module.
34
35use crate::nl_quadratic::{
36    FactoredQuadratic, Quad2, QuadForm, QuadHessian, is_expanded_quadratic, is_trivially_zero,
37    quad_form_readout, recognize_expr, recognize_factored_quadratic,
38};
39use crate::nl_tape::{HybridTape, Tape, hybrid_supported};
40use pounce_common::types::{Index, Number, lower_bound_present, upper_bound_present};
41use pounce_nlp::constant_derivatives::{DerivativeProof, DerivativeProofs};
42use pounce_nlp::quadratic::{QuadraticStructure, SquareTerm};
43use pounce_nlp::tnlp::{
44    BoundsInfo, IDX_NAMES, IndexStyle, IpoptCq, IpoptData, Linearity, MetaData, NlpInfo,
45    ScalingRequest, Solution, SparsityRequest, StartingPoint, TNLP,
46};
47use std::cell::RefCell;
48use std::collections::{BTreeMap, BTreeSet};
49use std::path::Path;
50use std::rc::Rc;
51use std::sync::Arc;
52
53#[derive(Debug, Clone)]
54pub enum Expr {
55    /// Numeric constant.
56    Const(Number),
57    /// Variable reference (0-based index into `x`).
58    Var(usize),
59    /// Binary op: `args = [lhs, rhs]`.
60    Binary(BinOp, Box<Expr>, Box<Expr>),
61    /// Unary op.
62    Unary(UnaryOp, Box<Expr>),
63    /// n-ary sum (opcode `o54` — variadic; we may emit it from `o0`
64    /// folding optimization, but the parser treats `o0` as binary).
65    Sum(Vec<Expr>),
66    /// Reference to a common subexpression (`.nl` `V` segment). The
67    /// payload is a shared body; many references to the same CSE share
68    /// one `Arc`, so the parsed problem is a DAG. Walking through `Cse`
69    /// is mathematically equivalent to inlining the body at each
70    /// occurrence (every reference is an independent occurrence in the
71    /// chain rule), so eval/grad/collect_vars just recurse into the
72    /// inner `Expr`. The pointer is atomically refcounted (`Arc`, not
73    /// `Rc`) so a parsed problem — and the `NlTnlp` built from it —
74    /// is `Send` and can move to a rayon worker for batched solving
75    /// (pounce#126); sharing is still read-only after parse.
76    Cse(Arc<Expr>),
77    /// AMPL imported (external) function call. `id` matches an entry in
78    /// `NlProblem.imported_funcs`; resolution to a live shared library
79    /// happens when the tape is built (see `nl_external::ExternalResolver`).
80    Funcall { id: usize, args: Vec<FuncallArg> },
81    /// Relational comparison (`o22`/`o23`/`o24`/`o28`/`o29`/`o30`).
82    /// Evaluates to `1.0` when the comparison holds, else `0.0`. The
83    /// result is piecewise-constant, so it has zero derivative
84    /// everywhere (the kink at equality is ignored — standard
85    /// subgradient-free treatment, matching ASL).
86    Compare(CmpOp, Box<Expr>, Box<Expr>),
87    /// Logical AND (`o21`). `1.0` iff both operands are nonzero.
88    /// Zero derivative (piecewise constant).
89    And(Box<Expr>, Box<Expr>),
90    /// Logical OR (`o20`). `1.0` iff either operand is nonzero.
91    /// Zero derivative (piecewise constant).
92    Or(Box<Expr>, Box<Expr>),
93    /// Logical NOT (`o34`). `1.0` iff the operand is zero.
94    /// Zero derivative (piecewise constant).
95    Not(Box<Expr>),
96    /// `if-then-else` (`o35` OPIFnl). Evaluates `cond`; when it is
97    /// nonzero the value and all derivatives flow through `then_`,
98    /// otherwise through `else_`. The branch switch is a non-smooth
99    /// event the derivative ignores (it differentiates only the
100    /// active branch), exactly as ASL/IPOPT does for `if`.
101    Cond {
102        cond: Box<Expr>,
103        then_: Box<Expr>,
104        else_: Box<Expr>,
105    },
106    /// n-ary minimum (`o11` MINLIST). Value is the smallest operand.
107    /// Piecewise linear: the derivative flows through whichever operand
108    /// is currently smallest (a subgradient; ties resolve to the first
109    /// such operand), and the second derivative is identically zero —
110    /// the standard AD treatment for min/max, matching ASL/IPOPT.
111    MinList(Vec<Expr>),
112    /// n-ary maximum (`o12` MAXLIST). Value is the largest operand;
113    /// derivative routing mirrors [`Expr::MinList`].
114    MaxList(Vec<Expr>),
115}
116
117/// Relational operator carried by [`Expr::Compare`]. The variants map
118/// 1:1 onto AMPL opcodes `o22 LT`, `o23 LE`, `o24 EQ`, `o28 GE`,
119/// `o29 GT`, `o30 NE`.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CmpOp {
122    Lt,
123    Le,
124    Eq,
125    Ge,
126    Gt,
127    Ne,
128}
129
130/// One positional argument to an AMPL imported function call. AMPL splits
131/// arguments into reals (carried by `ra[]`) and strings (carried by `sa[]`);
132/// `FuncallArg` mirrors that split. Real args are arbitrary expressions.
133#[derive(Debug, Clone)]
134pub enum FuncallArg {
135    Real(Expr),
136    Str(String),
137}
138
139/// An AMPL imported (external) function declaration from a top-level
140/// `F<id> <type> <nargs> <name>` segment.
141#[derive(Debug, Clone)]
142pub struct ImportedFunc {
143    pub id: usize,
144    /// 0 = real-valued, 1 = string-args (per AMPL's funcadd ABI).
145    pub kind: usize,
146    /// Declared arg count. >=0 exact arity; <=-1 means at least `-(nargs+1)`.
147    pub nargs: i64,
148    pub name: String,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum BinOp {
153    Add,
154    Sub,
155    Mul,
156    Div,
157    Pow,
158    /// Two-argument arctangent `atan2(a, b)` with operands `(y, x)`.
159    Atan2,
160    /// `a·ln(a/b)` — GAMS `centropy`. No `.nl` opcode; in-memory `Expr` only.
161    ///
162    /// Fused for the same reason as [`UnaryOp::XLogX`], plus one of its own:
163    /// `∂²/∂b²` is `a/b²`, and `b²` overflows for `|b| > 1.3e154` while
164    /// `a/b²` itself stays comfortably in range. The fused rule evaluates it
165    /// as `q/b` with `q = a/b` and never squares anything.
166    CEntropy,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum UnaryOp {
171    Neg,
172    Sqrt,
173    Log,
174    Exp,
175    Abs,
176    Sin,
177    Cos,
178    Log10,
179    Tan,
180    Atan,
181    Acos,
182    Sinh,
183    Cosh,
184    Tanh,
185    Asin,
186    Acosh,
187    Asinh,
188    Atanh,
189    /// Gauss error function. No `.nl` opcode maps here — AMPL has no `erf`,
190    /// so the parser never emits it — but the in-memory builder (issue #469)
191    /// does, which is the whole point: a frontend that constructs an `Expr`
192    /// directly is not limited to what `.nl` can spell.
193    Erf,
194    /// `a·ln(a)` — GAMS `entropy`. Like [`UnaryOp::Erf`], no `.nl` opcode maps
195    /// here; it is reachable only from an in-memory `Expr`.
196    ///
197    /// Fused rather than lowered to `Mul(a, Log(a))` because the chain rule
198    /// *cannot* produce its second derivative. `(a·ln a)'' = 1/a` is finite
199    /// wherever `a > 0` — at `a = 1e-299` it is `1e299` — but every
200    /// decomposition routes through `ln''(a) = -1/a² = -1e598`, which exceeds
201    /// `f64::MAX`. A composite that is in range, built from a factor that is
202    /// not, is unreachable by any chain rule however carefully written, so the
203    /// fusion is a correctness requirement rather than an optimization.
204    XLogX,
205}
206
207/// The `.nl` header's nonlinearity census — lines 3 and 5 of Gay's header
208/// table (*Hooking Your Solver to AMPL*, §D and Table 1).
209///
210/// AMPL has already done this analysis when it writes the file, so these are
211/// facts about the model that cost nothing to keep and would otherwise have
212/// to be recovered by walking every expression tree in it.
213///
214/// ```text
215///  55 1        # nonlinear constraints, objectives          -> nl_cons, nl_objs
216///  100 110 100 # nonlinear vars in constraints, objectives, both
217/// ```
218///
219/// Two properties of the format make these usable rather than merely
220/// informative, and both are asserted against the fixture corpus in
221/// `crates/pounce-cli/tests/nl_header_counts.rs`:
222///
223/// 1. **Nonlinear rows come first.** Constraints `0..nl_cons` are the ones
224///    with a nonlinear body; objectives `0..nl_objs` likewise.
225/// 2. **Nonlinear variables come first.** The `.nl` variable order is
226///    "nonlinear in both, then constraints-only, then objectives-only, then
227///    everything linear", so the variables that appear nonlinearly occupy a
228///    prefix of length [`NlCounts::nonlinear_vars`].
229///
230/// The counts are what the *writer* asserted, which is not always what the
231/// parsed trees say: `parse_nl_text` folds a variable-free `C` body into the
232/// row bounds (`gh #492`), so a row counted in `nl_cons` can arrive here with
233/// a nonlinear part of `Const(0.0)`. The discrepancy is one-directional —
234/// the header only ever over-states nonlinearity relative to the trees — and
235/// every consumer below is written to be sound under exactly that direction.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct NlCounts {
238    /// `nlc`: constraints with a nonlinear body.
239    pub nl_cons: usize,
240    /// `nlo`: objectives with a nonlinear body.
241    pub nl_objs: usize,
242    /// `nlvc`: variables appearing nonlinearly in constraints. **Includes**
243    /// the [`Self::nl_vars_both`] variables that are also nonlinear in an
244    /// objective.
245    pub nl_vars_cons: usize,
246    /// `nlvo`: variables appearing nonlinearly in objectives. Also includes
247    /// [`Self::nl_vars_both`].
248    pub nl_vars_objs: usize,
249    /// `nlvb`: variables appearing nonlinearly in *both* a constraint and an
250    /// objective. Counted a second and third time in the two fields above,
251    /// which is why the total is an inclusion–exclusion and not a sum.
252    pub nl_vars_both: usize,
253}
254
255impl NlCounts {
256    /// Number of distinct variables that appear nonlinearly anywhere:
257    /// `nlvc + nlvo − nlvb`, because `nlvb` is double-counted by the other
258    /// two. Saturating, so a malformed header cannot underflow.
259    ///
260    /// This is *not* `max(nlvc, nlvo)`: for `min x₀² s.t. x₁² ≤ 1` the counts
261    /// are `nlvc = nlvo = 1`, `nlvb = 0` and there are two nonlinear
262    /// variables, not one.
263    pub fn nonlinear_vars(&self) -> usize {
264        self.nl_vars_cons
265            .saturating_add(self.nl_vars_objs)
266            .saturating_sub(self.nl_vars_both)
267    }
268}
269
270/// The nonlinear body of the objective, or of one constraint row, as the
271/// parser left it.
272///
273/// Before gh #588 Q5 this was always an [`Expr`], and for most bodies it
274/// still is. The second variant exists because the `Expr` DAG is what sets
275/// peak RSS on a quadratic model — 2.32 M nodes for a ten-row
276/// `qcqp500-3c` — and every consumer of a *recognized* body wants the
277/// degree-≤2 coefficients rather than the tree they would have to walk to
278/// recover them. So the parser recognizes those bodies from the token
279/// stream and never builds the tree at all.
280///
281/// The distinction is deliberately impossible to ignore. Nine consumers
282/// read these bodies (§5.3 of `dev-notes/quadratic-structure-exploitation.md`
283/// enumerates them) and several would read a *missing* tree as "this row is
284/// linear" — a silent wrong answer. An enum makes every one of them a
285/// compile error until it says which reading it wants.
286#[derive(Debug, Clone)]
287pub enum NlBody {
288    /// The expression tree.
289    Tree(Expr),
290    /// A degree-2 form recognized while the token stream was consumed. The
291    /// tree was never built; [`NlProblem::con_expr`] rebuilds it on demand,
292    /// byte for byte, by re-parsing the same bytes with the same parser.
293    Quad(Box<QuadBody>),
294}
295
296/// A body the parser recognized as an already-expanded quadratic.
297#[derive(Debug, Clone)]
298pub struct QuadBody {
299    /// The recognized form. Bit-for-bit what
300    /// [`crate::nl_quadratic::recognize_expr`] returns for the tree these
301    /// same bytes parse to — asserted directly, over the whole corpus, by
302    /// `pounce-cli/tests/quad_parse_differential.rs`.
303    pub form: Quad2,
304    /// Every variable the body's token stream mentions, ascending and
305    /// deduplicated — exactly the set [`collect_vars`] reports for the tree,
306    /// **including** variables whose coefficient cancelled to zero (which
307    /// `form` necessarily drops). Structural consumers want this one; the
308    /// linearity contract is over-stating-is-safe, and a support taken from
309    /// `form` would under-state.
310    pub vars: Vec<u32>,
311    /// Byte range of this body's token stream inside [`NlProblem::src`].
312    pub src: std::ops::Range<usize>,
313    /// Nesting depth of the tree these tokens would have built, on the same
314    /// convention as a leaf counting 1.
315    ///
316    /// Recorded because the streaming recognizer is iterative and the tree
317    /// parser is not: a body deep enough to overflow the parser's stack now
318    /// *loads*, and the depth guard that used to be enforced implicitly by
319    /// the parse failing has to be enforced by something. `pounce-py`'s
320    /// `checked_depth` reads it (pounce #472). Rebuilding such a body with
321    /// [`NlProblem::con_expr`] would still recurse, which is the same
322    /// ceiling Q3 recorded and Q5 does not lift.
323    pub depth: u32,
324}
325
326impl NlBody {
327    /// The identity zero — "this row has no nonlinear part". A recognized
328    /// body is degree 2 by construction, so it is never trivially zero;
329    /// the question still has to be asked through here rather than by
330    /// matching on a tree that may not exist.
331    pub fn is_trivially_zero(&self) -> bool {
332        match self {
333            NlBody::Tree(e) => matches!(e, Expr::Const(c) if *c == 0.0),
334            NlBody::Quad(_) => false,
335        }
336    }
337
338    /// The recognized degree-2 form, when the parser produced one.
339    pub fn quad(&self) -> Option<&Quad2> {
340        match self {
341            NlBody::Tree(_) => None,
342            NlBody::Quad(q) => Some(&q.form),
343        }
344    }
345
346    /// The tree, when there is one resident. `None` for a recognized body —
347    /// use [`NlProblem::con_expr`] / [`NlProblem::obj_expr`] to rebuild it.
348    pub fn tree(&self) -> Option<&Expr> {
349        match self {
350            NlBody::Tree(e) => Some(e),
351            NlBody::Quad(_) => None,
352        }
353    }
354
355    /// This body as a degree-≤2 Hessian, or `None` if it is not provably
356    /// quadratic — [`crate::nl_quadratic::analyze_quadratic`] for a tree,
357    /// and the form the parser already proved for a recognized body. The
358    /// two answers are the same by construction and asserted to be so bit
359    /// for bit; this is the accessor that keeps the corpus from being
360    /// re-recognized once per consumer.
361    ///
362    /// `None` also when a term was dropped getting to the form — see
363    /// [`Self::analyze_quadratic_full`].
364    pub fn analyze_quadratic(&self) -> Option<QuadHessian> {
365        self.analyze_quadratic_full().map(|(h, _, _)| h)
366    }
367
368    /// [`Self::analyze_quadratic`] with the linear and constant parts.
369    ///
370    /// ## A form that dropped a term is not this body
371    ///
372    /// Everything downstream of here *reads coefficients out*: the
373    /// classifier decides a problem class from the Hessian, and
374    /// `qp_extract` builds `P`, `c`, `A` and `G` from all three parts. So
375    /// this accessor owes its callers a form that is the whole body, and
376    /// a recognized form is only that when nothing was dropped reaching
377    /// it. `2⁵³·x₀² + x₀² − 2⁵³·x₀²` folds to `x₀²` and stores nothing;
378    /// `(10⁻²⁰⁰·x₀)·(10⁻²⁰⁰·x₀)` underflows the same way (gh #683).
379    ///
380    /// Handing that form out is what routed the reproduction in gh #685
381    /// to the **LP** fast path: with the row's only quadratic term gone
382    /// the classifier saw a linear row, `qp_extract` folded an empty
383    /// linear part into `G`, and the constraint left the model
384    /// altogether — `min −x₀` subject to a vanished row walks `x₀` to its
385    /// `10⁶` bound and reports `Optimal`. A wrong answer, on the default
386    /// route, with no option set (gh #685 part 2).
387    ///
388    /// The gate is [`Quad2::lost_terms`] and not an emptiness test, for
389    /// the reason spelled out on [`Self::admitted_quad_form`]: partial
390    /// cancellation leaves a non-empty map that is still short a term.
391    /// Refusing costs reach only — the row falls back to the AD tape,
392    /// and the model to the NLP path, which solves it soundly.
393    ///
394    /// `lost_terms` is the *inexact fold* and not the drop it leads to
395    /// (gh #687), so the reach given up here is only the reach that has
396    /// to be. `x − x` cancels exactly — nothing was lost, the form is
397    /// the body, and it is still handed out; `2⁵³·x + x − 2⁵³·x` loses
398    /// the `x` at `fl(2⁵³ + 1)`, and that is what this refuses.
399    ///
400    /// Use [`Self::quad_terms_dropped`] to tell the two `None`s apart.
401    pub fn analyze_quadratic_full(&self) -> Option<QuadForm> {
402        match self {
403            NlBody::Tree(e) => {
404                let form = recognize_expr(e)?;
405                (!form.lost_terms()).then(|| quad_form_readout(&form))
406            }
407            NlBody::Quad(q) => (!q.form.lost_terms()).then(|| quad_form_readout(&q.form)),
408        }
409    }
410
411    /// Whether the recognizer reached a degree-≤2 form for this body but
412    /// lost at least one term getting there — the case
413    /// [`Self::analyze_quadratic_full`] refuses.
414    ///
415    /// `false` both for a body that recognized cleanly and for one that
416    /// did not recognize at all, so this separates the two reasons that
417    /// accessor answers `None`; it is not a nonlinearity test. Meant for
418    /// the *refusal* path (the classifier naming its reason), not the hot
419    /// one: on a tree it re-runs the recognizer.
420    pub fn quad_terms_dropped(&self) -> bool {
421        match self {
422            NlBody::Tree(e) => recognize_expr(e).is_some_and(|f| f.lost_terms()),
423            NlBody::Quad(q) => q.form.lost_terms(),
424        }
425    }
426
427    /// The form the constant-structure evaluator is allowed to use — i.e.
428    /// [`Self::analyze_quadratic_full`] behind the *exactness* gate.
429    ///
430    /// A recognized body has already passed that gate: the parser admits
431    /// only a flat sum of monomials, which is the same rule
432    /// [`crate::nl_quadratic::is_expanded_quadratic`] applies to a tree.
433    /// Reading a factored form out of stored coefficients cancels — five
434    /// digits on `(x − 500000)²` — so the gate is on both arms or on
435    /// neither (gh #588, Q4).
436    ///
437    /// A body this refuses for that reason is not out of reach, only out of
438    /// *this* representation: [`Self::admitted_factored_form`] serves it by
439    /// keeping the squares factored (gh #673), and only a body neither can
440    /// express keeps its tape.
441    ///
442    /// ## The second gate: a term that was *lost* is a term that is missing
443    ///
444    /// `is_expanded_quadratic` is a gate on the *shape* the coefficients
445    /// were derived from. It says nothing about whether the derivation kept
446    /// them. A flat sum of monomials passes it and still folds to a form
447    /// with an entry missing, because the fold is floating-point addition:
448    /// `2⁵³·x₀² + x₀² − 2⁵³·x₀²` is `x₀²` and stores nothing, and
449    /// `(10⁻²⁰⁰·x₀)·(10⁻²⁰⁰·x₀)` underflows the same way (gh #683).
450    ///
451    /// Evaluating **that** form is not a five-digit cancellation, it is a
452    /// missing term. At `x₀ = 3` the row reads `0` where its own tape reads
453    /// `16`, and `∂g/∂x` reads `[0, 0]` where the tape reads `[8, 0]` — so
454    /// the `≤` the row sits under stops constraining anything at all. In the
455    /// reproduction (`issue_685_cancelled_quadratic_evaluation`) the solve
456    /// then walks the objective variable to its `-10⁶` floor and reports
457    /// `Optimal`, where the same bytes down the tape stop at `-0.281`. On
458    /// the default route, with no option set. So the form is admitted only
459    /// when [`Quad2::lost_terms`] is clear (gh #685 part 1).
460    ///
461    /// It has to be that flag and not an emptiness test. Partial
462    /// cancellation is the same defect wearing a different face:
463    /// `2⁵³·x₀² + x₀² − 2⁵³·x₀² + x₁²` keeps `x₁²`, so the map is not empty
464    /// and [`Self::provably_affine`] answers `Some(false)` quite correctly —
465    /// while the read-out is still short an entire `x₀²`. A gate that looked
466    /// at emptiness would pass this and stay wrong.
467    ///
468    /// And it is the *loss*, not the drop. `x₀² − x₀²` folds through
469    /// `fl(1) + fl(−1) = 0` with nothing rounded away, so its read-out is
470    /// the whole body and it keeps this fast path; gating on the drop
471    /// refused it alongside the absorbing row above, for arithmetic that
472    /// lost nothing (gh #687).
473    ///
474    /// The cost is reach, not correctness: a row that lost a term goes back
475    /// to the AD tape, which is where it was before Q4.
476    pub fn admitted_quad_form(&self) -> Option<QuadForm> {
477        match self {
478            NlBody::Tree(e) => {
479                if is_trivially_zero(e) || !is_expanded_quadratic(e) {
480                    return None;
481                }
482                let form = recognize_expr(e)?;
483                (!form.lost_terms()).then(|| quad_form_readout(&form))
484            }
485            NlBody::Quad(q) => (!q.form.lost_terms()).then(|| quad_form_readout(&q.form)),
486        }
487    }
488
489    /// The **factored** form the constant-structure evaluator may use when
490    /// [`Self::admitted_quad_form`] refuses (gh #673).
491    ///
492    /// That accessor's gate is `is_expanded_quadratic`, and what it refuses
493    /// is a body whose read-out would be an algebraic *expansion* of what
494    /// the writer wrote — `(x − 500000)²` read back as
495    /// `x² − 10⁶x + 2.5·10¹¹`, five digits gone. The refusal was never
496    /// about the body being unsuitable for constant-structure evaluation;
497    /// it was about the *representation*. So a body written as a sum of
498    /// squared residuals — which is every least-squares model, and 41 of
499    /// `airport.nl`'s 42 rows — is served here instead, by keeping the
500    /// writer's own grouping and squaring it at evaluation time exactly as
501    /// the tape does. See
502    /// [`recognize_factored_quadratic`](crate::nl_quadratic::recognize_factored_quadratic)
503    /// for what is admitted and why it costs no accuracy.
504    ///
505    /// Answers `None` for a body the parser recognized: a
506    /// [`NlBody::Quad`] is an already-flat sum of monomials by
507    /// construction, so it has no factoring left to keep and
508    /// [`Self::admitted_quad_form`] has already served it.
509    ///
510    /// ## This arm answers for the bodies refused on *shape*, and only those
511    ///
512    /// [`Self::admitted_quad_form`] says `None` for two different reasons,
513    /// and only one of them is this arm's. A body refused because its shape
514    /// is factored is what this serves. A body refused because a term went
515    /// **missing** in the fold ([`Quad2::lost_terms`], gh #685) keeps its
516    /// tape, and the explicit `is_expanded_quadratic` test here is what
517    /// keeps it there: those bodies are flat sums of monomials, so the
518    /// square-shaped ones among them — `2⁵³x₀² + x₀² − 2⁵³x₀²` is three —
519    /// would otherwise be admitted here by the back door.
520    ///
521    /// What breaks when they are is worth stating, because it is *not* that
522    /// the fast path becomes less accurate. Measured with this test
523    /// removed: the row whose tape answers `16.0` at `x₀ = 3` is answered
524    /// `9.0` by the factored arm — and `9.0` is the mathematically right
525    /// value of `x₀²`, which the compensated outer sum (gh #702) recovers
526    /// and the tape's naive fold does not. End to end the reproduction
527    /// moves from `−1.812` to `−2.236`, which is `−√5`, the true optimum of
528    /// the model those bytes describe.
529    ///
530    /// It is still a defect, for the reason this file's own doc comment
531    /// gives: the tape is the reference because it is what the row means
532    /// *to this solver*, not because it is exact. Two routes over the same
533    /// bytes that answer `9` and `16` are a `POUNCE_DBG_NO_QUAD`-shaped
534    /// divergence whichever one is closer to the algebra. (The Hessian
535    /// **pattern** diverges too — `Σ 2wₖbₖbₖᵀ` folds that row's `(0, 0)` to
536    /// exactly `0.0`, `2⁵⁴ + 2` tying back to `2⁵⁴`, and a zero entry is
537    /// not stored where the tape declares one: `nnz_h` 2 → 1.)
538    ///
539    /// Pinned by
540    /// `a_row_that_dropped_a_term_is_not_admitted_as_a_factored_form_either`.
541    ///
542    /// Callers must still try [`Self::admitted_quad_form`] first: both can
543    /// answer for the same body (a bare `x²` is a monomial and a square),
544    /// and the expanded arm is the cheaper evaluation — a matvec over a
545    /// merged row rather than one squaring per term.
546    pub fn admitted_factored_form(&self) -> Option<FactoredQuadratic> {
547        match self {
548            NlBody::Tree(e) => {
549                if is_trivially_zero(e) || is_expanded_quadratic(e) {
550                    return None;
551                }
552                recognize_factored_quadratic(e)
553            }
554            NlBody::Quad(_) => None,
555        }
556    }
557
558    /// Whether this body is provably **affine** — degree ≤ 1 — as a
559    /// three-valued answer: `Some(true)` proved affine, `Some(false)`
560    /// proved to have a nonzero second derivative, `None` no proof
561    /// either way.
562    ///
563    /// This is the degree question *without* the exactness gate
564    /// [`Self::admitted_quad_form`] applies, and the difference is the
565    /// point (gh #588, Q6). That gate exists because reading a value out
566    /// of stored coefficients cancels for a factored form; nothing is
567    /// read out here. The answer is used to decide whether a derivative
568    /// may be **reused** across iterates — a question about the
569    /// *degree* of the body, which a factored `(x − a)²` answers just as
570    /// well as an expanded one.
571    ///
572    /// `None` is not evidence of nonlinearity: the recognizer refuses
573    /// `2·(x + 1)`, which is affine. Consumers must treat it as "not
574    /// established".
575    ///
576    /// ## What the exactness argument above still does not buy
577    ///
578    /// It is true that nothing is *evaluated* from the coefficients here.
579    /// What the argument missed is that the **degree answer is itself
580    /// computed by the coefficient arithmetic**: the recognizer sums a
581    /// row's quadratic coefficients in floating point and drops the ones
582    /// that reach exactly zero, so `2⁵³·x² + x² − 2⁵³·x²` — and
583    /// `(10⁻²⁰⁰·x)·(10⁻²⁰⁰·x)`, by underflow — folded to an empty
584    /// quadratic map and were reported *proved affine*. Q6's consumer then
585    /// froze those rows' Jacobians for the whole solve (gh #683).
586    ///
587    /// So an empty quadratic map is a proof of degree ≤ 1 only when no term
588    /// went missing getting there, which is what
589    /// [`Quad2::lost_terms`](crate::nl_quadratic::Quad2::lost_terms)
590    /// records. When one did, this answers `None` — the state the contract
591    /// already reserved for "not established", which is why the fix needs
592    /// nothing of its consumer.
593    ///
594    /// A term that cancelled **exactly** did not go missing, and gh #687 is
595    /// where that stopped costing a proof: `x₀² − x₀²` is degree 0 by an
596    /// add that rounded nothing, its tape holds `∂g/∂x` at zero for every
597    /// `x`, and answering `None` for it gave up a whole solve of frozen
598    /// Jacobian to be safe from arithmetic that never happened.
599    ///
600    /// Deliberately answers from the term maps rather than from
601    /// [`Self::analyze_quadratic_full`]'s triplets: on `qssp180` that is
602    /// 65 341 recognized rows, and materializing a `QuadHessian` per row
603    /// to ask whether it is empty is the allocation-per-object cost Q3
604    /// removed from the recognizer in the first place.
605    pub fn provably_affine(&self) -> Option<bool> {
606        match self {
607            NlBody::Tree(e) => {
608                if is_trivially_zero(e) {
609                    return Some(true);
610                }
611                affine_from_form(&recognize_expr(e)?)
612            }
613            NlBody::Quad(q) => affine_from_form(&q.form),
614        }
615    }
616
617    /// Add this body's structural variable support to `out`.
618    pub fn collect_vars(&self, out: &mut BTreeSet<usize>) {
619        match self {
620            NlBody::Tree(e) => collect_vars(e, out),
621            NlBody::Quad(q) => out.extend(q.vars.iter().map(|&v| v as usize)),
622        }
623    }
624}
625
626/// Give one body a constant-structure form, if either representation will
627/// take it, and return its id.
628///
629/// The order is the contract [`NlBody::admitted_factored_form`] states:
630/// the expanded read-out first, because it is the cheaper evaluation and
631/// because it is what the `qcqp*` family — the target of gh #588's Q4 —
632/// arrives as; the factored one only for the bodies that gate refuses
633/// (gh #673). A body neither admits keeps its AD tape, which is where every
634/// body was before Q4.
635fn push_body_form(quad: &mut QuadraticStructure, body: &NlBody) -> Option<u32> {
636    if let Some((h, lin, c)) = body.admitted_quad_form() {
637        return Some(quad.push_form(&h, &lin, c));
638    }
639    let fq = body.admitted_factored_form()?;
640    let terms: Vec<SquareTerm<'_>> = fq
641        .squares
642        .iter()
643        .map(|t| SquareTerm {
644            weight: t.weight,
645            coefs: &t.coefs,
646            constant: t.constant,
647        })
648        .collect();
649    // `None` here is the gh #685 gate on this arm: the body is factored,
650    // but assembling `Σ 2wₖbₖbₖᵀ` dropped an entry the tape will declare.
651    // Falls through to the tape, like any other refusal.
652    quad.push_factored_form(&terms, &fq.linear, fq.constant)
653}
654
655/// The degree read-out [`NlBody::provably_affine`] makes of a recognized
656/// form, in one place so both arms answer it the same way.
657///
658/// A stored quadratic coefficient is a witness that the body is degree 2.
659/// An *absent* one is only a witness of the opposite when nothing went
660/// missing on the way — see [`Quad2::lost_terms`], gh #683 and gh #687.
661fn affine_from_form(q: &Quad2) -> Option<bool> {
662    if q.quadratic().is_empty() {
663        (!q.lost_terms()).then_some(true)
664    } else {
665        Some(false)
666    }
667}
668
669impl From<Expr> for NlBody {
670    fn from(e: Expr) -> Self {
671        NlBody::Tree(e)
672    }
673}
674
675/// Parsed `.nl` problem in the form needed by `NlTnlp`.
676#[derive(Debug, Clone)]
677pub struct NlProblem {
678    pub n: usize,
679    pub m: usize,
680    pub num_obj: usize,
681    pub minimize: bool,
682    pub obj_nonlinear: NlBody,
683    pub obj_linear: Vec<(usize, Number)>,
684    pub obj_constant: Number,
685    /// Per-constraint nonlinear part (length m).
686    pub con_nonlinear: Vec<NlBody>,
687    /// Per-constraint linear part (length m), each a list of (var, coef).
688    pub con_linear: Vec<Vec<(usize, Number)>>,
689    pub x_l: Vec<Number>,
690    pub x_u: Vec<Number>,
691    pub g_l: Vec<Number>,
692    pub g_u: Vec<Number>,
693    pub x0: Vec<Number>,
694    /// Initial constraint multipliers from the `d` segment, in the engine's
695    /// sign (`L = f + lambda' g`, `f` negated for a maximize model):
696    /// `-sigma * dual` for the AMPL duals the file carries, the inverse of
697    /// the CLI's `.sol` writer (gh#1033).
698    pub lambda0: Vec<Number>,
699    /// AMPL suffix dictionaries. Variable / constraint / objective
700    /// suffixes are stored as dense vectors (length n / m / num_obj)
701    /// with the sparse `.nl` `S`-segment entries scattered in, default
702    /// zero. The integer / real split matches the `S`-segment header's
703    /// kind bit (`0x4` ⇒ real, else integer). See
704    /// <https://ampl.com/REFS/hooking2.pdf> §6 and the upstream `.nl`
705    /// reader in `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`.
706    pub suffixes: NlSuffixes,
707    /// The model's own AMPL option words, taken verbatim from `.nl`
708    /// header line 0 (`g<count> <opt0> <opt1> ...`). A solver echoes
709    /// these back in the `.sol` `Options` block rather than interpreting
710    /// them — see [`crate::sol_writer::format_sol_with_options`]. Empty
711    /// for problems not built from a `.nl` file.
712    pub ampl_options: Vec<i64>,
713    /// The header's nonlinearity census, when the problem came from a `.nl`
714    /// file whose header parsed cleanly. `None` for a model built in memory
715    /// ([`NlProblem::from_expressions`]) — there is no header to read, and
716    /// inventing one would let a consumer trust a count nobody computed.
717    pub nl_counts: Option<NlCounts>,
718    /// Header line 7's discrete-variable census: declared binary
719    /// and general-integer variables (gh#987). `None` when there was no header
720    /// or it did not parse. pounce solves the continuous relaxation, so a
721    /// nonzero count means the answer is a bound, not a MIP solution.
722    pub n_discrete: Option<DiscreteCensus>,
723    /// AMPL imported (external) functions declared via top-level `F` segments.
724    /// Empty unless the `.nl` file calls compiled-C user functions (typically
725    /// emitted by IDAES property packages — see issue #49).
726    pub imported_funcs: Vec<ImportedFunc>,
727    /// Variable names from the sibling `.col` file, index-aligned to `x`
728    /// (one name per line, column order). Empty when no `.col` file was
729    /// found — AMPL only emits it under `option auxfiles rc;`.
730    ///
731    /// Carrying names lets diagnostics report `flow_balance` / `T_reactor`
732    /// instead of `c[3]` / `x[132]`. Lee et al. (2024) identify the gap
733    /// between detecting an issue and tracing it to a *named* equation as a
734    /// central roadblock for equation-oriented model debugging; threading
735    /// names through to the solver/debugger is the prerequisite for closing
736    /// it. See <https://doi.org/10.69997/sct.147875>.
737    pub var_names: Vec<String>,
738    /// Constraint names from the sibling `.row` file, index-aligned to `g`
739    /// (one name per line, row order). Empty when no `.row` file was found.
740    /// See [`NlProblem::var_names`] for why names are captured.
741    pub con_names: Vec<String>,
742    /// The `.nl` text this problem was parsed from, kept only when some
743    /// body was recognized as a quadratic and therefore has no tree of its
744    /// own. It is what makes [`NlProblem::con_expr`] able to hand back the
745    /// *exact* tree rather than a re-derived one: same bytes, same parser,
746    /// same `Expr`. `None` for [`NlProblem::from_expressions`] models and
747    /// for a parse that recognized nothing.
748    ///
749    /// The text is resident during parsing either way, so keeping it costs
750    /// nothing at the peak this is all in aid of — see
751    /// `dev-notes/quadratic-structure-exploitation.md` §0f.
752    pub src: Option<Arc<String>>,
753    /// The `V`-segment common subexpressions, by CSE-local index. Held so a
754    /// re-parse resolves `v<i>` (`i >= n`) to the *same* `Arc` the original
755    /// parse did — `HybridTape::build_multi` keys sharing on pointer
756    /// identity, so a rebuilt body that allocated fresh bodies would look
757    /// unshared.
758    pub cse_bodies: Vec<Arc<Expr>>,
759}
760
761/// The pieces of a model built in memory, as handed to
762/// [`NlProblem::from_expressions`].
763///
764/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
765/// split to fill in, because the AD tape treats a linear term exactly like
766/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
767/// optimization, not an evaluator requirement). `n` is taken from the length
768/// of `x_l`; `m` from the length of `constraints`.
769///
770/// One cost to that simplification, in *metadata* rather than values: with
771/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
772/// when its expression is literally `Const(0.0)`, so a genuinely linear row
773/// built here reports `NonLinear`. Presolve consumes that tag, and the
774/// direction is the safe one — it loses tightening it could have done, and
775/// never asserts linearity that does not hold — but a frontend that cares
776/// about presolve strength on linear rows should know the tag is
777/// pessimistic on this path.
778#[derive(Debug, Clone)]
779pub struct NlProblemParts {
780    /// `true` to minimize `objective`, `false` to maximize it. Matches
781    /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
782    /// so callers always see the minimization form.
783    pub minimize: bool,
784    /// Objective expression.
785    pub objective: Expr,
786    /// Constant offset added to the objective.
787    pub obj_constant: Number,
788    /// One expression per constraint row; row `i` is bounded by
789    /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
790    pub constraints: Vec<Expr>,
791    /// Variable bounds and starting point, each length `n`. Use `±1e19`
792    /// for "unbounded", the same sentinel the `.nl` reader emits.
793    pub x_l: Vec<Number>,
794    pub x_u: Vec<Number>,
795    pub x0: Vec<Number>,
796    /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
797    /// equality row.
798    pub g_l: Vec<Number>,
799    pub g_u: Vec<Number>,
800    /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
801    /// consumer falls back to indices (see [`NlProblem::var_names`]).
802    pub var_names: Vec<String>,
803    pub con_names: Vec<String>,
804}
805
806impl NlProblem {
807    /// Assemble a problem from expression trees, with no `.nl` file
808    /// anywhere in the loop (issue #469).
809    ///
810    /// A modeling frontend that already has its own expression DAG should
811    /// come in here rather than serialize to `.nl` and re-parse: the round
812    /// trip is not only slower, it is *lossy*, because `.nl` writers
813    /// routinely refuse operators this tape supports natively (`atan2`,
814    /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
815    ///
816    /// The result is an ordinary [`NlProblem`], so it feeds
817    /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
818    /// does: objective, gradient, constraints, Jacobian + structure,
819    /// Lagrangian Hessian + structure, and
820    /// [`NlTnlp::hessian_vector_product`].
821    ///
822    /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
823    /// — the latter would otherwise be an out-of-bounds read in the tape's
824    /// forward sweep, so it must be caught while it is still a diagnosable
825    /// user error.
826    pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
827        let NlProblemParts {
828            minimize,
829            objective,
830            obj_constant,
831            constraints,
832            x_l,
833            x_u,
834            x0,
835            g_l,
836            g_u,
837            var_names,
838            con_names,
839        } = parts;
840
841        let n = x_l.len();
842        let m = constraints.len();
843        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
844            if got == want {
845                Ok(())
846            } else {
847                Err(format!(
848                    "from_expressions: {name} has length {got}, expected {want}"
849                ))
850            }
851        };
852        check("x_u", x_u.len(), n)?;
853        check("x0", x0.len(), n)?;
854        check("g_l", g_l.len(), m)?;
855        check("g_u", g_u.len(), m)?;
856        if !var_names.is_empty() {
857            check("var_names", var_names.len(), n)?;
858        }
859        if !con_names.is_empty() {
860            check("con_names", con_names.len(), m)?;
861        }
862
863        // Numeric validation, the same screen the `.nl` reader applies
864        // (gh #847). `lower_bound_present` / `upper_bound_present` are
865        // `is_finite() && ...`, so a caller that builds a model here and hands
866        // in a non-finite bound gets it silently dropped -- read as "no bound
867        // declared" -- exactly as a file containing `1e400` was. `-inf` on a
868        // lower side and `+inf` on an upper one are the sentinel said a
869        // different way and are normalized; everything else, `NaN` included,
870        // is refused.
871        let mut x_l = x_l;
872        let mut x_u = x_u;
873        let mut g_l = g_l;
874        let mut g_u = g_u;
875        for (i, v) in x_l.iter_mut().enumerate() {
876            *v = finite_bound_or_err(&format!("x_l[{i}]"), *v, true)
877                .map_err(|e| format!("from_expressions: {e}"))?;
878        }
879        for (i, v) in x_u.iter_mut().enumerate() {
880            *v = finite_bound_or_err(&format!("x_u[{i}]"), *v, false)
881                .map_err(|e| format!("from_expressions: {e}"))?;
882        }
883        for (i, v) in g_l.iter_mut().enumerate() {
884            *v = finite_bound_or_err(&format!("g_l[{i}]"), *v, true)
885                .map_err(|e| format!("from_expressions: {e}"))?;
886        }
887        for (i, v) in g_u.iter_mut().enumerate() {
888            *v = finite_bound_or_err(&format!("g_u[{i}]"), *v, false)
889                .map_err(|e| format!("from_expressions: {e}"))?;
890        }
891        for (i, v) in x0.iter().enumerate() {
892            finite_or_err(&format!("x0[{i}]"), *v).map_err(|e| format!("from_expressions: {e}"))?;
893        }
894        finite_or_err("obj_constant", obj_constant)
895            .map_err(|e| format!("from_expressions: {e}"))?;
896
897        // Structural validation. Memoized on `Cse` pointer identity so a
898        // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
899        let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
900        validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
901        for (i, c) in constraints.iter().enumerate() {
902            validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
903        }
904
905        Ok(NlProblem {
906            n,
907            m,
908            num_obj: 1,
909            minimize,
910            obj_nonlinear: NlBody::Tree(objective),
911            obj_linear: Vec::new(),
912            obj_constant,
913            con_nonlinear: constraints.into_iter().map(NlBody::Tree).collect(),
914            con_linear: vec![Vec::new(); m],
915            x_l,
916            x_u,
917            g_l,
918            g_u,
919            x0,
920            lambda0: vec![0.0; m],
921            suffixes: NlSuffixes::default(),
922            imported_funcs: Vec::new(),
923            ampl_options: Vec::new(),
924            // No header was read, so there is no census to report. Consumers
925            // fall back to walking the trees, which is what they would have
926            // to do here anyway.
927            nl_counts: None,
928            n_discrete: None,
929            var_names,
930            con_names,
931            // Built from trees, so every body has one and there is nothing
932            // to rebuild from.
933            src: None,
934            cse_bodies: Vec::new(),
935        })
936    }
937
938    /// The objective's nonlinear body as an [`Expr`].
939    ///
940    /// Borrowed when the tree is resident, rebuilt when the parser
941    /// recognized the body and skipped building it. The rebuild re-parses
942    /// the body's own bytes with the same parser that produced the rest of
943    /// the model, so the result is the tree a non-recognizing parse would
944    /// have produced — structurally identical, coefficient bit patterns
945    /// included, and sharing the same `Cse` allocations.
946    ///
947    /// It is not free: it allocates the nodes the recognizer avoided. Reach
948    /// for [`NlBody::quad`] first if the coefficients are what you want.
949    pub fn obj_expr(&self) -> std::borrow::Cow<'_, Expr> {
950        self.body_expr(&self.obj_nonlinear, "objective")
951    }
952
953    /// Row `k`'s nonlinear body as an [`Expr`]. See [`Self::obj_expr`].
954    ///
955    /// # Panics
956    ///
957    /// Panics if `k >= m`, or if re-parsing a recognized body fails — the
958    /// latter cannot happen for a problem this crate produced (the bytes
959    /// parsed once already, by this code) and means the `NlProblem` was
960    /// assembled by hand with a `src` that does not match its bodies.
961    pub fn con_expr(&self, k: usize) -> std::borrow::Cow<'_, Expr> {
962        self.body_expr(&self.con_nonlinear[k], "constraint")
963    }
964
965    fn body_expr<'a>(&'a self, body: &'a NlBody, what: &str) -> std::borrow::Cow<'a, Expr> {
966        match body {
967            NlBody::Tree(e) => std::borrow::Cow::Borrowed(e),
968            NlBody::Quad(q) => {
969                let src = self
970                    .src
971                    .as_deref()
972                    .unwrap_or_else(|| panic!("{what} body was recognized but no source is kept"));
973                std::borrow::Cow::Owned(
974                    parse_body_fragment(&src[q.src.clone()], self.n, &self.cse_bodies)
975                        .unwrap_or_else(|e| panic!("re-parsing a recognized {what} body: {e}")),
976                )
977            }
978        }
979    }
980}
981
982/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
983/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
984///
985/// Both are things the tape cannot recover from later. An out-of-range
986/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
987/// worse-looking than it is fatal: `from_expressions` has nowhere to put
988/// the `F`-segment declarations an AMPL imported function needs
989/// (`NlProblemParts` has no field for them, and the built problem's
990/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
991/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
992/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
993/// failure to "funcall id N has no F<N> declaration". Rejecting it here
994/// says the true thing: this door does not carry external functions; go
995/// through `read_nl` / `parse_nl_text` for a model that needs them.
996///
997/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
998/// passing one set through a whole problem keeps the walk linear in
999/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
1000/// repeat visit is sound because the caller aborts on the first violation:
1001/// reaching a body a second time proves the first visit found none.
1002fn validate_expr(
1003    e: &Expr,
1004    n: usize,
1005    seen: &mut std::collections::HashSet<*const Expr>,
1006) -> Result<(), String> {
1007    match e {
1008        Expr::Const(_) => Ok(()),
1009        Expr::Var(i) => {
1010            if *i < n {
1011                Ok(())
1012            } else {
1013                Err(format!("references Var({i}) but n = {n}"))
1014            }
1015        }
1016        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1017            validate_expr(a, n, seen)?;
1018            validate_expr(b, n, seen)
1019        }
1020        Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
1021        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1022            for a in args {
1023                validate_expr(a, n, seen)?;
1024            }
1025            Ok(())
1026        }
1027        Expr::Cond { cond, then_, else_ } => {
1028            validate_expr(cond, n, seen)?;
1029            validate_expr(then_, n, seen)?;
1030            validate_expr(else_, n, seen)
1031        }
1032        Expr::Cse(body) => {
1033            if seen.insert(Arc::as_ptr(body)) {
1034                validate_expr(body, n, seen)
1035            } else {
1036                Ok(())
1037            }
1038        }
1039        Expr::Funcall { id, .. } => Err(format!(
1040            "references AMPL imported function id {id}, which this path cannot \
1041             resolve: a problem built from expressions has no F-segment \
1042             declarations to bind it to. Load such a model with read_nl or \
1043             parse_nl_text instead."
1044        )),
1045    }
1046}
1047
1048/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
1049/// into dense vectors at problem load time so callers can index by
1050/// variable / constraint number directly. Empty maps when the `.nl`
1051/// file declared no suffixes.
1052#[derive(Debug, Clone, Default)]
1053pub struct NlSuffixes {
1054    /// Variable-level integer suffixes (kind = 0). Each vector has
1055    /// length `n_full` (problem variables).
1056    pub var_int: BTreeMap<String, Vec<Index>>,
1057    /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
1058    pub con_int: BTreeMap<String, Vec<Index>>,
1059    /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
1060    pub obj_int: BTreeMap<String, Vec<Index>>,
1061    /// Problem-level integer suffixes (kind = 3). Single value per name.
1062    pub problem_int: BTreeMap<String, Index>,
1063    /// Variable-level real suffixes (kind = 4). Length `n_full`.
1064    pub var_real: BTreeMap<String, Vec<Number>>,
1065    /// Constraint-level real suffixes (kind = 5). Length `m_full`.
1066    pub con_real: BTreeMap<String, Vec<Number>>,
1067    /// Objective-level real suffixes (kind = 6). Length `num_obj`.
1068    pub obj_real: BTreeMap<String, Vec<Number>>,
1069    /// Problem-level real suffixes (kind = 7). Single value per name.
1070    pub problem_real: BTreeMap<String, Number>,
1071}
1072
1073/// Deepest expression nesting the reader will build (gh#986). On a thread
1074/// whose stack is declared with [`set_parse_stack_budget`] the limit is
1075/// lowered to what that stack holds at the worst-case frame size; the CLI
1076/// runs on a 1 GiB thread and `pounce-py` on a 1 GiB worker (each falling
1077/// back to a smaller reservation, and a correspondingly lower limit, when the
1078/// OS refuses). A left-deep `o0` sum is read flat and does not count.
1079pub const MAX_PARSE_DEPTH: u32 = 40_000;
1080
1081thread_local! {
1082    /// Stack bytes the current thread offers the recursive reader
1083    /// (gh#986), set by whoever spawned it with a known stack. `None` (the
1084    /// default) means "unknown": [`DEFAULT_ASSUMED_STACK`] is assumed.
1085    static PARSE_STACK_BUDGET: std::cell::Cell<Option<usize>> =
1086        const { std::cell::Cell::new(None) };
1087}
1088
1089/// Tell the reader how much stack this thread has, so its depth guard fires
1090/// before the stack does at *any* build profile (gh#986). Call it first thing
1091/// on a thread created with an explicit stack size (the CLI's main thread, the
1092/// Python worker); `None` clears it.
1093pub fn set_parse_stack_budget(bytes: Option<usize>) {
1094    PARSE_STACK_BUDGET.with(|b| b.set(bytes));
1095}
1096
1097/// Stack the recursive reader (parse, tape build, drop glue) needs per
1098/// nesting level, as a worst case over build profiles. Measured on a
1099/// negation chain 39 900 deep: an unoptimised build (`opt-level = 0`, what
1100/// `cargo build` / `maturin develop` produce) fails at 512 MiB and passes at
1101/// 1 GiB, i.e. 13–26 KiB per level; optimised builds use well under 2 KiB.
1102const PARSE_LEVEL_BYTES: usize = if cfg!(debug_assertions) {
1103    32 * 1024
1104} else {
1105    4 * 1024
1106};
1107
1108/// Stack a thread is assumed to have when nobody declared one with
1109/// [`set_parse_stack_budget`] (gh#986 review). The first pass gave such a
1110/// thread the full [`MAX_PARSE_DEPTH`], sized for the CLI's 1 GiB thread, so a
1111/// library caller of [`read_nl_file`] on an ordinary thread, or the wasm
1112/// build, aborted on a stack overflow where the guard was meant to refuse.
1113/// The assumption is the smallest stack such a caller normally runs on: Rust's
1114/// default for a spawned thread (2 MiB, also what the test harness uses) on
1115/// native targets, and the 1 MiB `rustc` gives a wasm32 module.
1116const DEFAULT_ASSUMED_STACK: usize = if cfg!(target_arch = "wasm32") {
1117    1 << 20
1118} else {
1119    2 << 20
1120};
1121
1122/// The depth limit for the current thread: [`MAX_PARSE_DEPTH`], lowered to
1123/// what the thread's declared stack holds -- or, undeclared, what
1124/// [`DEFAULT_ASSUMED_STACK`] holds.
1125fn parse_depth_limit() -> u32 {
1126    let bytes = PARSE_STACK_BUDGET
1127        .with(|b| b.get())
1128        .unwrap_or(DEFAULT_ASSUMED_STACK);
1129    ((bytes / PARSE_LEVEL_BYTES).min(MAX_PARSE_DEPTH as usize)) as u32
1130}
1131
1132/// A run of binary `o0` longer than this is read as one n-ary `Sum`
1133/// (gh#986); shorter runs keep their binary tree.
1134const SUM_CHAIN_FLATTEN_MIN: usize = 64;
1135
1136/// Parse an `.nl` file from disk.
1137///
1138/// After parsing the `.nl` body, this also looks for AMPL's optional
1139/// sibling name files — `stub.col` (variable names) and `stub.row`
1140/// (constraint names), emitted only when the modeler sets
1141/// `option auxfiles rc;`. When present and well-formed they populate
1142/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
1143/// malformed the names stay empty and every downstream consumer falls
1144/// back to indices. Names are a diagnostic nicety, never load-blocking
1145/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1146pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
1147    // AMPL invokes a solver with an extensionless *stub* — e.g.
1148    // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
1149    // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
1150    // path as given is missing but appending `.nl` names an existing file,
1151    // resolve to that. This only ever *adds* a fallback: an existing path
1152    // is read verbatim, so nothing changes for callers that already pass a
1153    // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
1154    let resolved = if path.exists() {
1155        path.to_path_buf()
1156    } else {
1157        let with_nl = append_extension(path, "nl");
1158        if with_nl.exists() {
1159            with_nl
1160        } else {
1161            path.to_path_buf()
1162        }
1163    };
1164    let txt = std::fs::read_to_string(&resolved)
1165        .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
1166    // By value: a recognized body keeps a byte range into this text rather
1167    // than a tree, so it is moved into the problem instead of copied.
1168    let mut prob = parse_nl_string(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())?;
1169    prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
1170    prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
1171    Ok(prob)
1172}
1173
1174/// Append `.ext` to `path`'s full file name (AMPL stub convention:
1175/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
1176/// which would *replace* an existing extension. A stub that itself
1177/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
1178/// way AMPL names it.
1179fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
1180    let mut name = path.as_os_str().to_os_string();
1181    name.push(".");
1182    name.push(ext);
1183    std::path::PathBuf::from(name)
1184}
1185
1186/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
1187/// order. Returns the first `expected` names, or an empty vector when the
1188/// file is missing, unreadable, or has fewer than `expected` lines.
1189///
1190/// Returning empty (rather than erroring) on any mismatch is deliberate:
1191/// names are an optional diagnostic aid, so a missing or truncated file
1192/// must never block a solve. The `.take(expected)` also drops AMPL's
1193/// convention of appending the objective name after the constraint names
1194/// in `.row`, keeping the result aligned 1:1 with `g`.
1195fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
1196    let Ok(txt) = std::fs::read_to_string(path) else {
1197        return Vec::new();
1198    };
1199    let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
1200    if names.len() == expected {
1201        names
1202    } else {
1203        Vec::new()
1204    }
1205}
1206
1207/// The value of a constraint's nonlinear part when that part is a
1208/// constant, else `None`. Drives the constant-row-body fold in
1209/// [`parse_nl_text`].
1210///
1211/// "Constant" is decided by *evaluation*, not by syntax: a literal
1212/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
1213/// and is folded too.
1214///
1215/// Declines in two cases, both of which would make the fold unsound:
1216/// * The expression calls an AMPL imported function. Its value depends on
1217///   a shared library resolved much later (`nl_external::ExternalResolver`),
1218///   so it is not a parse-time constant even with constant arguments — and
1219///   [`eval_expr`] panics on `Funcall` rather than guess.
1220/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
1221///   a NaN or infinity into a bound would corrupt a row that is merely
1222///   infeasible; leaving the expression in place keeps it a solver-time
1223///   fact.
1224fn row_constant_value(e: &Expr) -> Option<Number> {
1225    // The identity zero the parser preallocates for every untouched row is
1226    // by far the most common input; settle it without walking anything.
1227    if let Expr::Const(c) = e {
1228        return c.is_finite().then_some(*c);
1229    }
1230    let mut vars: BTreeSet<usize> = BTreeSet::new();
1231    collect_vars(e, &mut vars);
1232    if !vars.is_empty() {
1233        return None;
1234    }
1235    let mut funcs: BTreeSet<usize> = BTreeSet::new();
1236    crate::nl_external::collect_funcall_ids(e, &mut funcs);
1237    if !funcs.is_empty() {
1238        return None;
1239    }
1240    // Variable-free, so no `Expr::Var` can index into the (empty) point.
1241    let v = eval_expr(e, &[]);
1242    v.is_finite().then_some(v)
1243}
1244
1245/// Parse `.nl` text content. Public so tests can use string literals.
1246///
1247/// Degree-2 bodies are recognized as the token stream is read, so their
1248/// `Expr` trees are never built (gh #588, Q5); `POUNCE_DBG_NO_QUAD=1`
1249/// turns that off and restores the pre-Q5 parse exactly, which is what the
1250/// A/B reference and gh #540's guard test need. See
1251/// [`parse_nl_text_with_quadratic`] for the same switch as a parameter.
1252pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
1253    parse_nl_text_with_quadratic(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
1254}
1255
1256/// [`parse_nl_text`] with parse-time quadratic recognition explicitly on
1257/// or off.
1258///
1259/// The env var the plain entry point reads is process-global, which is
1260/// exactly wrong for a differential test that has to parse the *same* text
1261/// both ways in one process and compare the results — so the knob is a
1262/// parameter here, mirroring [`NlTnlp::try_new_with_quadratic`].
1263pub fn parse_nl_text_with_quadratic(txt: &str, use_quadratic: bool) -> Result<NlProblem, String> {
1264    parse_nl_string(txt.to_string(), use_quadratic)
1265}
1266
1267/// [`parse_nl_text_with_quadratic`], taking the text by value.
1268///
1269/// A recognized body keeps a byte range into the source rather than a tree,
1270/// so the source outlives the parse. Taking ownership means the file
1271/// [`read_nl_file`] already read is *moved* into the problem instead of
1272/// copied: the text is resident during parsing either way, so this costs
1273/// nothing at the peak the phase is about.
1274pub fn parse_nl_string(txt: String, use_quadratic: bool) -> Result<NlProblem, String> {
1275    let src = Arc::new(txt);
1276    let mut p = Parser::new(&src, use_quadratic);
1277    p.parse_header()?;
1278    let n = p.n;
1279    let m = p.m;
1280    let num_obj = p.num_obj;
1281
1282    let mut con_nonlinear: Vec<NlBody> = (0..m).map(|_| NlBody::Tree(Expr::Const(0.0))).collect();
1283    let mut obj_nonlinear = NlBody::Tree(Expr::Const(0.0));
1284    let mut minimize = true;
1285    let mut obj_linear: Vec<(usize, Number)> = Vec::new();
1286    let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1287    let mut x_l = vec![-1e19; n];
1288    let mut x_u = vec![1e19; n];
1289    let mut g_l = vec![-1e19; m];
1290    let mut g_u = vec![1e19; m];
1291    let mut x0 = vec![0.0; n];
1292    let mut lambda0 = vec![0.0; m];
1293    let mut suffixes = NlSuffixes::default();
1294    let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
1295    // Segment presence, for the truncation check after the loop (gh#785).
1296    let mut saw_r = false;
1297    let mut saw_b = false;
1298
1299    while let Some(line) = p.peek_segment_line() {
1300        let tag = line
1301            .trim_start()
1302            .chars()
1303            .next()
1304            .ok_or("unexpected blank segment header")?;
1305        match tag {
1306            'C' => {
1307                let (_hdr, rest) = p.eat_segment_header()?;
1308                let _ = rest;
1309                let idx = parse_segment_index(_hdr, 'C')?;
1310                if idx >= m {
1311                    return Err(format!("C{idx} out of range; m={m}"));
1312                }
1313                con_nonlinear[idx] = p.parse_body()?;
1314            }
1315            'O' => {
1316                let (hdr, _rest) = p.eat_segment_header()?;
1317                let parts: Vec<&str> = hdr.split_whitespace().collect();
1318                if parts.len() < 2 {
1319                    return Err(format!("malformed O-segment header: {hdr}"));
1320                }
1321                let idx = parse_segment_index(parts[0], 'O')?;
1322                let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
1323                if idx == 0 {
1324                    minimize = kind == 0;
1325                    obj_nonlinear = p.parse_body()?;
1326                } else {
1327                    // Extra objectives are read but ignored.
1328                    let _ = p.parse_expr()?;
1329                }
1330            }
1331            'r' => {
1332                p.eat_segment_header()?;
1333                saw_r = true;
1334                for i in 0..m {
1335                    let line = p.next_data_line()?;
1336                    let (lo, hi) = parse_bound_line(line)?;
1337                    g_l[i] = lo;
1338                    g_u[i] = hi;
1339                }
1340            }
1341            'b' => {
1342                p.eat_segment_header()?;
1343                saw_b = true;
1344                for i in 0..n {
1345                    let line = p.next_data_line()?;
1346                    let (lo, hi) = parse_bound_line(line)?;
1347                    x_l[i] = lo;
1348                    x_u[i] = hi;
1349                }
1350            }
1351            'k' => {
1352                // Column counts in the Jacobian; we don't need their
1353                // values for evaluation (the J segments give explicit
1354                // lists), but we must consume exactly as many data lines
1355                // as follow or the segment stream desyncs. The `.nl`
1356                // format writes that line count in the header itself
1357                // (`k<count>`), and the standard value is `n-1`. Read the
1358                // declared count rather than assuming it: a file with a
1359                // nonstandard count would otherwise leave us reading the
1360                // wrong number of lines, swallowing a later segment header
1361                // (or stopping short) and failing with a confusing,
1362                // far-removed error. Validate against the expected `n-1`
1363                // so a mismatch surfaces here, clearly, at its source.
1364                let (hdr, _) = p.eat_segment_header()?;
1365                let declared = parse_segment_index(hdr, 'k')?;
1366                let expected = if n == 0 { 0 } else { n - 1 };
1367                if declared != expected {
1368                    return Err(format!(
1369                        "k-segment declares {declared} column-count lines but \
1370                         the standard count for n={n} variables is {expected}"
1371                    ));
1372                }
1373                for _ in 0..declared {
1374                    p.next_data_line()?;
1375                }
1376            }
1377            'J' => {
1378                let (hdr, _) = p.eat_segment_header()?;
1379                let parts: Vec<&str> = hdr.split_whitespace().collect();
1380                if parts.len() < 2 {
1381                    return Err(format!("malformed J-segment header: {hdr}"));
1382                }
1383                let row = parse_segment_index(parts[0], 'J')?;
1384                let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
1385                if row >= m {
1386                    return Err(format!("J{row} out of range"));
1387                }
1388                for _ in 0..nz {
1389                    let line = p.next_data_line()?;
1390                    let (var, coef) = parse_var_coef(line)?;
1391                    // Validate the column index here: an out-of-range `var`
1392                    // would otherwise be stored and panic as a slice OOB
1393                    // (`x[var]`) during constraint evaluation. Mirror the
1394                    // clean parse error used for the row index above.
1395                    if var >= n {
1396                        return Err(format!(
1397                            "J{row} entry variable index {var} out of range (n={n})"
1398                        ));
1399                    }
1400                    con_linear[row].push((var, coef));
1401                }
1402            }
1403            'G' => {
1404                let (hdr, _) = p.eat_segment_header()?;
1405                let parts: Vec<&str> = hdr.split_whitespace().collect();
1406                if parts.len() < 2 {
1407                    return Err(format!("malformed G-segment header: {hdr}"));
1408                }
1409                let idx = parse_segment_index(parts[0], 'G')?;
1410                let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
1411                let mut acc = Vec::with_capacity(nz);
1412                for _ in 0..nz {
1413                    let line = p.next_data_line()?;
1414                    let (var, coef) = parse_var_coef(line)?;
1415                    // Same as J: reject an out-of-range gradient column index
1416                    // up front rather than letting it panic on `x[var]` later.
1417                    if var >= n {
1418                        return Err(format!(
1419                            "G{idx} entry variable index {var} out of range (n={n})"
1420                        ));
1421                    }
1422                    acc.push((var, coef));
1423                }
1424                if idx == 0 {
1425                    obj_linear = acc;
1426                }
1427            }
1428            'x' => {
1429                let (hdr, _) = p.eat_segment_header()?;
1430                let parts: Vec<&str> = hdr.split_whitespace().collect();
1431                let nx: usize = parts
1432                    .first()
1433                    .and_then(|s| s.trim_start_matches('x').parse().ok())
1434                    .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
1435                for _ in 0..nx {
1436                    let line = p.next_data_line()?;
1437                    let (idx, val) = parse_var_coef(line)?;
1438                    // Reject out-of-range indices as a parse error, matching
1439                    // J/G strictness, rather than silently dropping the entry
1440                    // (which hides a corrupt initial-primal segment).
1441                    if idx >= n {
1442                        return Err(format!(
1443                            "x-segment variable index {idx} out of range (n={n})"
1444                        ));
1445                    }
1446                    x0[idx] = val;
1447                }
1448            }
1449            'd' => {
1450                let (hdr, _) = p.eat_segment_header()?;
1451                let parts: Vec<&str> = hdr.split_whitespace().collect();
1452                let nd: usize = parts
1453                    .first()
1454                    .and_then(|s| s.trim_start_matches('d').parse().ok())
1455                    .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
1456                for _ in 0..nd {
1457                    let line = p.next_data_line()?;
1458                    let (idx, val) = parse_var_coef(line)?;
1459                    // Reject out-of-range indices as a parse error, matching
1460                    // J/G strictness, rather than silently dropping the entry
1461                    // (which hides a corrupt initial-dual segment).
1462                    if idx >= m {
1463                        return Err(format!(
1464                            "d-segment constraint index {idx} out of range (m={m})"
1465                        ));
1466                    }
1467                    // A `d` entry is in AMPL's dual sign (`d obj / d b`, the
1468                    // sign a `.sol` dual block carries), and pounce's
1469                    // multiplier is that of `L = f + lambda' g`, i.e. `-dual`.
1470                    // `sol_writer` negates on the way out; negate on the way
1471                    // in so a `.nl` start and a `.sol` result share one sign
1472                    // (gh#1033; matches Ipopt's AMPL driver).
1473                    lambda0[idx] = -val;
1474                }
1475            }
1476            'V' => p.parse_v_segment()?,
1477            'S' => {
1478                parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
1479            }
1480            'F' => {
1481                // AMPL imported (external) function declaration:
1482                // `F<k> <type> <nargs> <name>`.
1483                let (hdr, _rest) = p.eat_segment_header()?;
1484                let parts: Vec<&str> = hdr.split_whitespace().collect();
1485                if parts.is_empty() {
1486                    return Err(format!("malformed F-segment header: '{hdr}'"));
1487                }
1488                let id = parse_segment_index(parts[0], 'F')?;
1489                let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1490                let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1491                let name = parts.get(3).copied().unwrap_or("").to_string();
1492                imported_funcs.push(ImportedFunc {
1493                    id,
1494                    kind,
1495                    nargs,
1496                    name,
1497                });
1498            }
1499            other => return Err(format!("unknown .nl segment tag '{other}'")),
1500        }
1501    }
1502
1503    // A `.nl` file that ends early is not a smaller model — it is a corrupt
1504    // one, and every segment the truncation ate is a piece of the problem
1505    // that silently reverts to a default: a dropped `r` leaves every row at
1506    // ±1e19 (i.e. unconstrained), a dropped `b` leaves every variable free,
1507    // a dropped `J` leaves every row's linear part empty. The segment loop
1508    // cannot tell those defaults from a legitimately absent segment, so the
1509    // truncated model solves, and reports `SolveSucceeded` with a
1510    // confidently wrong objective — which is strictly worse than the parse
1511    // error every other malformed input already gets (gh#785). The header
1512    // declares enough to tell the two apart; check it here, once, against
1513    // what the segments actually delivered.
1514    //
1515    // `r` and `b` are unconditional in the format: AMPL writes both whenever
1516    // the model has rows / columns at all, free rows and free variables
1517    // included — those get the `3` ("no bounds") code, not an omitted line.
1518    if m > 0 && !saw_r {
1519        return Err(format!(
1520            "missing `r` (constraint-bounds) segment for a model declaring {m} \
1521             constraint(s): the .nl file is truncated or corrupt"
1522        ));
1523    }
1524    if n > 0 && !saw_b {
1525        return Err(format!(
1526            "missing `b` (variable-bounds) segment for a model declaring {n} \
1527             variable(s): the .nl file is truncated or corrupt"
1528        ));
1529    }
1530    // The `J` segments must deliver exactly the Jacobian nonzero count the
1531    // header declares — `nzc` is the sum of their lengths by construction,
1532    // nonlinear-only columns included (they are written with a zero
1533    // coefficient, not omitted). This is the check that catches a truncation
1534    // landing *after* `r` and `b`, where the bounds are all present and only
1535    // the coefficients are gone.
1536    if let Some(declared) = p.declared_jac_nnz {
1537        let parsed: usize = con_linear.iter().map(Vec::len).sum();
1538        if parsed != declared {
1539            return Err(format!(
1540                "header declares {declared} Jacobian nonzero(s) but the J \
1541                 segments supply {parsed}: the .nl file is truncated or corrupt"
1542            ));
1543        }
1544    }
1545
1546    // Normalize constant row bodies into the row bounds, so that
1547    // "the nonlinear part is the identity zero" and "this row's body is
1548    // Σaⱼxⱼ" mean the same thing for every downstream consumer.
1549    //
1550    // A `C<i>` segment holding a variable-free expression (`n3.0`, or
1551    // anything that evaluates to a constant such as `o0 n1 n2`) is an
1552    // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
1553    // which every consumer reads as "nonlinear": the linearity predicate
1554    // (`get_constraints_linearity`), which is what makes presolve's
1555    // linear-equality reduction decline the row; the CLI problem
1556    // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
1557    // polynomial walk absorbs a bare literal but not a constant it has to
1558    // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
1559    // NLP and never reached the convex path); and the FBBT translator.
1560    // Folding here fixes all of them at once, with no per-consumer audit.
1561    //
1562    // The shift is exact and invisible from outside: the body drops by `c`
1563    // and each bound drops by `c` with it, so the feasible set, the active
1564    // set, and the duals are unchanged (`gh #492`). This is the same
1565    // normalization `qp_extract::analyze_quadratic_full` already performs
1566    // ad hoc via `const_shift`, promoted to the parse boundary.
1567    for i in 0..m {
1568        // A recognized body is degree 2, so it is not a constant row and
1569        // this fold has nothing to do with it.
1570        let Some(tree) = con_nonlinear[i].tree() else {
1571            continue;
1572        };
1573        let Some(c) = row_constant_value(tree) else {
1574            continue;
1575        };
1576        // Presence is directional (gh #401): shifting an *absent* bound
1577        // would turn the ±1e19 sentinel into a real bound for `c < 0`
1578        // (lower) or `c > 0` (upper), inventing a constraint. Leave the
1579        // sentinels alone.
1580        if lower_bound_present(g_l[i]) {
1581            g_l[i] -= c;
1582        }
1583        if upper_bound_present(g_u[i]) {
1584            g_u[i] -= c;
1585        }
1586        con_nonlinear[i] = NlBody::Tree(Expr::Const(0.0));
1587    }
1588
1589    // The source is kept only when something in it has no tree of its own.
1590    // A model with nothing recognized is byte-for-byte the pre-Q5 problem,
1591    // extra field included.
1592    let any_recognized =
1593        obj_nonlinear.quad().is_some() || con_nonlinear.iter().any(|b| b.quad().is_some());
1594    let kept_src = any_recognized.then(|| Arc::clone(&src));
1595
1596    // A `d` entry was stored as `-dual` above, which is the engine's
1597    // multiplier only for a minimize model. A maximize model is solved as
1598    // `min -f`, and the CLI writes its `.sol` dual as `-sigma * lambda`
1599    // (sigma = -1, gh#959), so the inverse is `lambda = -sigma * dual`.
1600    // Applied here because the `O` segment fixing the sense may follow `d`.
1601    if !minimize {
1602        for v in lambda0.iter_mut() {
1603            *v = -*v;
1604        }
1605    }
1606    Ok(NlProblem {
1607        n,
1608        m,
1609        num_obj,
1610        minimize,
1611        obj_nonlinear,
1612        obj_linear,
1613        obj_constant: 0.0,
1614        con_nonlinear,
1615        con_linear,
1616        x_l,
1617        x_u,
1618        g_l,
1619        g_u,
1620        x0,
1621        lambda0,
1622        suffixes,
1623        ampl_options: p.ampl_options.clone(),
1624        nl_counts: p.nl_counts,
1625        n_discrete: p.n_discrete,
1626        imported_funcs,
1627        // `.nl` text carries no names; `read_nl_file` fills these from the
1628        // sibling `.col`/`.row` files when present.
1629        var_names: Vec::new(),
1630        con_names: Vec::new(),
1631        src: kept_src,
1632        cse_bodies: p.cses.clone(),
1633    })
1634}
1635
1636/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
1637/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
1638///
1639/// ```text
1640/// S<kind> <nentries> <suffix_name>
1641/// <idx> <value>      ... nentries lines
1642/// ```
1643///
1644/// `<kind>` is a 3-bit encoding:
1645/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
1646///   2 = objectives, 3 = problem-level.
1647/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
1648///
1649/// Sparse entries scatter into a freshly-allocated dense vector (zero
1650/// default), sized for the target dimension. Problem-level suffixes
1651/// (kind = 3 / 7) carry a single value.
1652fn parse_suffix_segment(
1653    p: &mut Parser,
1654    n: usize,
1655    m: usize,
1656    num_obj: usize,
1657    out: &mut NlSuffixes,
1658) -> Result<(), String> {
1659    let (hdr, _) = p.eat_segment_header()?;
1660    let parts: Vec<&str> = hdr.split_whitespace().collect();
1661    if parts.len() < 3 {
1662        return Err(format!(
1663            "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
1664        ));
1665    }
1666    let kind_str = parts[0].trim_start_matches('S');
1667    let kind: u32 = kind_str
1668        .parse()
1669        .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
1670    let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
1671    let name = parts[2].to_string();
1672
1673    let is_real = (kind & 0x4) != 0;
1674    let target = kind & 0x3;
1675    let target_dim = match target {
1676        0 => n,
1677        1 => m,
1678        2 => num_obj,
1679        3 => 0, // problem-level — entries are single-valued (idx=0)
1680        _ => unreachable!("kind & 0x3 is in 0..=3"),
1681    };
1682
1683    // Pre-allocate dense buffers (default zero). Problem-level kinds
1684    // (3 / 7) hold a single scalar — we still read the (idx, value)
1685    // pairs but only the value field is meaningful.
1686    let mut int_buf: Vec<Index> = if !is_real && target != 3 {
1687        vec![0; target_dim]
1688    } else {
1689        Vec::new()
1690    };
1691    let mut real_buf: Vec<Number> = if is_real && target != 3 {
1692        vec![0.0; target_dim]
1693    } else {
1694        Vec::new()
1695    };
1696    let mut problem_int: Index = 0;
1697    let mut problem_real: Number = 0.0;
1698
1699    for _ in 0..nentries {
1700        let line = p.next_data_line()?;
1701        let parts: Vec<&str> = line.split_whitespace().collect();
1702        if parts.len() < 2 {
1703            return Err(format!(
1704                "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
1705            ));
1706        }
1707        let idx: usize = parts[0]
1708            .parse()
1709            .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
1710        if target != 3 && idx >= target_dim {
1711            return Err(format!(
1712                "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
1713            ));
1714        }
1715        if is_real {
1716            let v: Number = parts[1]
1717                .parse()
1718                .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
1719            if target == 3 {
1720                problem_real = v;
1721            } else {
1722                real_buf[idx] = v;
1723            }
1724        } else {
1725            let v: Index = parts[1]
1726                .parse()
1727                .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
1728            if target == 3 {
1729                problem_int = v;
1730            } else {
1731                int_buf[idx] = v;
1732            }
1733        }
1734    }
1735
1736    match (target, is_real) {
1737        (0, false) => {
1738            out.var_int.insert(name, int_buf);
1739        }
1740        (1, false) => {
1741            out.con_int.insert(name, int_buf);
1742        }
1743        (2, false) => {
1744            out.obj_int.insert(name, int_buf);
1745        }
1746        (3, false) => {
1747            out.problem_int.insert(name, problem_int);
1748        }
1749        (0, true) => {
1750            out.var_real.insert(name, real_buf);
1751        }
1752        (1, true) => {
1753            out.con_real.insert(name, real_buf);
1754        }
1755        (2, true) => {
1756            out.obj_real.insert(name, real_buf);
1757        }
1758        (3, true) => {
1759            out.problem_real.insert(name, problem_real);
1760        }
1761        _ => unreachable!(),
1762    }
1763    Ok(())
1764}
1765
1766fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1767    let trimmed = s.trim_start_matches(tag);
1768    trimmed
1769        .parse()
1770        .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1771}
1772
1773// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1774// `G` / `x` / `d` data line, so between them they see every Jacobian and
1775// gradient nonzero in the file. Both walk the whitespace iterator
1776// directly instead of collecting a `Vec<&str>` first — that collect was
1777// a heap allocation per line on top of the one the reader used to make
1778// handing the line over.
1779/// Refuse a non-finite number read out of a `.nl` file (gh #847).
1780///
1781/// `str::parse::<f64>()` accepts `inf`, `-inf` and `nan`, and it also *returns*
1782/// `inf` for any literal that overflows the type — `1e400` is a plausible thing
1783/// for a model generator to write. Nothing downstream treats such a value as an
1784/// error, and in one place it is actively misread: `lower_bound_present` /
1785/// `upper_bound_present` are `is_finite() && ...`, so a non-finite bound is
1786/// indistinguishable from a bound that was never declared, and is silently
1787/// dropped. On a model that a lower bound of `1e300` makes infeasible, the same
1788/// bound written `1e400` returned `EXIT: Optimal Solution Found.` with exit code
1789/// 0. A `nan` is worse: it propagates into the answer, and the solve reports
1790/// `Objective: nan` under `Solve_Succeeded`.
1791///
1792/// Ipopt refuses this input ("Invalid number"), POUNCE's own NLP arm refuses it,
1793/// and `pounce.solve_qp` refuses a non-finite bound with a bespoke `ValueError`.
1794/// There is no reading on which `Optimal` is the intended answer, so the reader
1795/// refuses it too.
1796fn finite_or_err(what: &str, v: Number) -> Result<Number, String> {
1797    if v.is_finite() {
1798        Ok(v)
1799    } else {
1800        Err(format!(
1801            "invalid number: {what} is {v}, which is not finite"
1802        ))
1803    }
1804}
1805
1806/// The same screen for a *bound* slot, where one non-finite value has an
1807/// unambiguous meaning and is normalized instead of refused.
1808///
1809/// `.nl` states "no bound" with a bound **kind** (1 = upper only, 2 = lower
1810/// only, 3 = free), so a non-finite number in a bound slot is a corrupt value
1811/// rather than a notation — with one exception per side. `-inf` in a *lower*
1812/// slot and `+inf` in an *upper* slot say precisely what the `±1e19` sentinel
1813/// says, and a writer that emits them means it, so they map to the sentinel.
1814///
1815/// Everything else is refused, and the asymmetry is the whole point: `+inf` as
1816/// a *lower* bound is the gh #847 case. It is not "unbounded below" — it is an
1817/// empty box, and reading it as "absent" is what turned an infeasible model
1818/// into an `Optimal` one. `NaN` is refused on either side, having no meaning at
1819/// all.
1820fn finite_bound_or_err(what: &str, v: Number, lower: bool) -> Result<Number, String> {
1821    if v.is_finite() {
1822        return Ok(v);
1823    }
1824    if lower && v == Number::NEG_INFINITY {
1825        return Ok(-1e19);
1826    }
1827    if !lower && v == Number::INFINITY {
1828        return Ok(1e19);
1829    }
1830    Err(format!(
1831        "invalid number: {what} is {v}, which is not finite (a `.nl` file \
1832         states an absent bound with a bound kind of 1, 2 or 3, not with a \
1833         non-finite value)"
1834    ))
1835}
1836
1837fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1838    let mut parts = line.split_whitespace();
1839    let kind: i32 = parts
1840        .next()
1841        .ok_or("empty bound line")?
1842        .parse()
1843        .map_err(|e| format!("bound kind: {e}"))?;
1844    let lo;
1845    let hi;
1846    match kind {
1847        0 => {
1848            // 0  lo  hi
1849            let (l, h) = (parts.next(), parts.next());
1850            let (Some(l), Some(h)) = (l, h) else {
1851                return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1852            };
1853            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1854            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1855        }
1856        1 => {
1857            // 1  hi
1858            let Some(h) = parts.next() else {
1859                return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1860            };
1861            lo = -1e19;
1862            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1863        }
1864        2 => {
1865            // 2  lo
1866            let Some(l) = parts.next() else {
1867                return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1868            };
1869            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1870            hi = 1e19;
1871        }
1872        3 => {
1873            // 3  (free)
1874            lo = -1e19;
1875            hi = 1e19;
1876        }
1877        4 => {
1878            // 4  eq
1879            let Some(v) = parts.next() else {
1880                return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1881            };
1882            // An equality has no "absent" side, so neither infinity is a
1883            // notation here and both are refused.
1884            let v: Number = finite_or_err("eq bound", v.parse().map_err(|e| format!("eq: {e}"))?)?;
1885            lo = v;
1886            hi = v;
1887        }
1888        5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1889        other => return Err(format!("unknown bound kind {other}")),
1890    }
1891    Ok((lo, hi))
1892}
1893
1894fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1895    let mut parts = line.split_whitespace();
1896    let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1897        return Err(format!("malformed var/coef line: '{line}'"));
1898    };
1899    let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1900    let c: Number = finite_or_err("coefficient", c.parse().map_err(|e| format!("coef: {e}"))?)?;
1901    Ok((v, c))
1902}
1903
1904/// Build an [`NlCounts`] from `.nl` header lines 3 (`nlc nlo`) and 5
1905/// (`nlvc nlvo nlvb`).
1906///
1907/// Returns `None` unless both lines carry the full complement of
1908/// non-negative integers, so a truncated or non-conforming header reads as
1909/// "unknown" rather than as zeros — "no nonlinear variables" is a claim, and
1910/// a header that failed to parse has not made it.
1911fn parse_nl_counts(line3: &str, line5: &str) -> Option<NlCounts> {
1912    let nums = |line: &str, want: usize| -> Option<Vec<usize>> {
1913        let v: Vec<usize> = line
1914            .split_whitespace()
1915            .take(want)
1916            .map(str::parse)
1917            .collect::<Result<_, _>>()
1918            .ok()?;
1919        (v.len() == want).then_some(v)
1920    };
1921    let cons_objs = nums(line3, 2)?;
1922    let vars = nums(line5, 3)?;
1923    Some(NlCounts {
1924        nl_cons: cons_objs[0],
1925        nl_objs: cons_objs[1],
1926        nl_vars_cons: vars[0],
1927        nl_vars_objs: vars[1],
1928        nl_vars_both: vars[2],
1929    })
1930}
1931
1932/// Header line 7: `nbv niv nlvbi nlvci nlvoi` (gh#987).
1933///
1934/// In AMPL's convention `nbv` and `niv` count only the discrete variables
1935/// that appear **linearly**; a discrete variable that appears nonlinearly is
1936/// counted in `nlvbi` / `nlvci` / `nlvoi` instead, *not* in `nbv + niv`. So a
1937/// MINLP whose integers all appear nonlinearly has `nbv = niv = 0` — reading
1938/// only those two fields reports it as continuous.
1939#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1940pub struct DiscreteCensus {
1941    /// `nbv`: binary variables appearing only linearly.
1942    pub binary: usize,
1943    /// `niv`: other integer variables appearing only linearly.
1944    pub integer: usize,
1945    /// `nlvbi`: integer variables nonlinear in both constraints and objectives.
1946    pub nl_both: usize,
1947    /// `nlvci`: integer variables nonlinear in constraints only.
1948    pub nl_cons: usize,
1949    /// `nlvoi`: integer variables nonlinear in objectives only.
1950    pub nl_objs: usize,
1951}
1952
1953impl DiscreteCensus {
1954    /// Total declared discrete variables, linear and nonlinear
1955    /// (`nbv + niv + nlvbi + nlvci + nlvoi`).
1956    pub fn total(&self) -> usize {
1957        self.binary + self.integer + self.nonlinear_discrete()
1958    }
1959
1960    /// Discrete variables that appear nonlinearly (`nlvbi + nlvci + nlvoi`).
1961    pub fn nonlinear_discrete(&self) -> usize {
1962        self.nl_both + self.nl_cons + self.nl_objs
1963    }
1964
1965    /// The 0-based columns of every discrete variable, from Gay's variable
1966    /// ordering ("Writing .nl Files", table 4):
1967    ///
1968    /// 1. nonlinear in both constraints and objectives — `nlvb` columns, the
1969    ///    last `nlvbi` of them integer;
1970    /// 2. nonlinear in constraints only — `nlvc − nlvb` columns, the last
1971    ///    `nlvci` integer;
1972    /// 3. nonlinear in objectives only — `nlvo − nlvc` columns (when
1973    ///    positive), the last `nlvoi` integer;
1974    /// 4. linear variables, ending in `nbv` binary then `niv` integer, which
1975    ///    are therefore the last `nbv + niv` columns of the model.
1976    ///
1977    /// `None` when the columns cannot be identified: a nonlinear integer
1978    /// count is non-zero but the header's line-5 census is missing, or the
1979    /// counts are inconsistent with the block sizes. Callers must report that
1980    /// as *not checked*, never as passed.
1981    pub fn integer_columns(&self, n: usize, counts: Option<NlCounts>) -> Option<Vec<usize>> {
1982        let lin = self.binary + self.integer;
1983        if lin > n {
1984            return None;
1985        }
1986        let mut cols = Vec::with_capacity(self.total());
1987        if self.nonlinear_discrete() > 0 {
1988            let c = counts?;
1989            let nlvb = c.nl_vars_both;
1990            let nlvc = c.nl_vars_cons;
1991            let nlvo = c.nl_vars_objs;
1992            if nlvc < nlvb {
1993                return None;
1994            }
1995            let b3 = nlvo.saturating_sub(nlvc);
1996            let blocks = [
1997                (0, nlvb, self.nl_both),
1998                (nlvb, nlvc - nlvb, self.nl_cons),
1999                (nlvc, b3, self.nl_objs),
2000            ];
2001            for (start, len, ints) in blocks {
2002                if ints > len || start + len > n - lin {
2003                    return None;
2004                }
2005                cols.extend(start + len - ints..start + len);
2006            }
2007        }
2008        cols.extend(n - lin..n);
2009        Some(cols)
2010    }
2011}
2012
2013struct Parser<'a> {
2014    lines: Vec<&'a str>,
2015    /// Start address and length of the source text. A recognized body
2016    /// records the byte *range* it consumed so it can be re-parsed later
2017    /// (see [`QuadBody::src`]), and every line borrows from this buffer, so
2018    /// one subtraction per line recovers its offset.
2019    ///
2020    /// Deliberately not a per-line offset table: on a 119 MB generated
2021    /// `qcqp500-3c` the file is ~17 M lines, and a `Vec<usize>` beside the
2022    /// existing `Vec<&str>` would add 136 MB to the peak this phase exists
2023    /// to reduce.
2024    txt_base: usize,
2025    txt_len: usize,
2026    pos: usize,
2027    n: usize,
2028    m: usize,
2029    num_obj: usize,
2030    /// Number of AMPL imported (external) functions declared in the header.
2031    n_funcs: usize,
2032    /// Header lines 3 and 5, when both parsed. See [`NlCounts`].
2033    nl_counts: Option<NlCounts>,
2034    /// `(nbv, niv)` from header line 7.
2035    n_discrete: Option<DiscreteCensus>,
2036    /// `nzc` from header line 8: the number of Jacobian nonzeros the file
2037    /// *declares*. [`parse_nl_string`] cross-checks it against the number the
2038    /// `J` segments actually deliver, which is how a file truncated before
2039    /// them is told from a model that genuinely has none (gh#785). `None`
2040    /// when the header does not carry it in the documented shape.
2041    declared_jac_nnz: Option<usize>,
2042    ampl_options: Vec<i64>,
2043    /// Common subexpressions (`V` segments). Index in this vec is the
2044    /// CSE-local index, i.e. the global `.nl` index minus `n`.
2045    cses: Vec<Arc<Expr>>,
2046    /// Recognize degree-2 bodies from the token stream instead of building
2047    /// their trees (gh #588, Q5). Off restores the pre-Q5 parse exactly.
2048    quad_enabled: bool,
2049    /// Per-CSE answers the streaming recognizer needs about a `V` body it
2050    /// may be handed a reference to, all keyed by CSE-local index: the
2051    /// body's own degree-≤2 form, whether it is legal on a sum spine,
2052    /// whether it is legal *inside* a monomial, and its variable support.
2053    ///
2054    /// These are computed from the built `V` body with the same functions
2055    /// `NlTnlp` would apply to a whole row, so a reference costs a lookup
2056    /// and cannot drift from what the tree walk would have said. `V` bodies
2057    /// keep their trees regardless — they are shared, so the memory is
2058    /// amortized over every reference, and dropping them would mean
2059    /// rebuilding them for the rows that are *not* recognized.
2060    cse_quad: Vec<Option<Quad2>>,
2061    cse_sum_ok: Vec<bool>,
2062    cse_mono_ok: Vec<bool>,
2063    cse_vars: Vec<Vec<u32>>,
2064    cse_depth: Vec<u32>,
2065    /// gh#986 review: the depth of every `V` body *through* the defined
2066    /// variables it references (a `Cse` counting as its body), keyed by the
2067    /// body's `Arc` pointer. Always kept, whatever `quad_enabled` says: it is
2068    /// what bounds the recursion of every later pass that walks through a
2069    /// `Cse` (tape build, `collect_vars`, evaluation, drop glue), and it makes
2070    /// [`expr_tree_depth`] linear in the body rather than in the whole chain.
2071    cse_depth_memo: std::collections::HashMap<*const Expr, u32>,
2072    /// `V` body pointer -> its index in `cses` (and in the `cse_*` caches),
2073    /// so the parse-time passes can read an earlier body's verdict instead of
2074    /// re-walking it (gh#986 review).
2075    cse_index: std::collections::HashMap<*const Expr, usize>,
2076    /// Current recursion depth of `parse_expr` (gh#986).
2077    parse_depth: u32,
2078    /// Depth at which `parse_expr` refuses (gh#986); see [`parse_depth_limit`].
2079    max_depth: u32,
2080}
2081
2082/// One pending operator on the streaming recognizer's stack.
2083struct QFrame {
2084    op: QOp,
2085    /// Operands still to be read.
2086    remaining: usize,
2087    /// Read this frame's operands in monomial mode — no `+`/`-` may appear
2088    /// below it. See [`crate::nl_quadratic::is_expanded_quadratic`].
2089    mono: bool,
2090}
2091
2092/// The operators the streaming recognizer accepts. One variant per shape
2093/// that [`crate::nl_quadratic::recognize_expr`] handles; everything else
2094/// makes it bail.
2095enum QOp {
2096    Neg,
2097    Add,
2098    Sub,
2099    Mul,
2100    Div,
2101    /// `o5`/`o81`/`o83`: base and exponent both read.
2102    Pow,
2103    /// `o82`: square, exponent implicit.
2104    Square,
2105    Sum(usize),
2106}
2107
2108impl<'a> Parser<'a> {
2109    fn new(txt: &'a str, quad_enabled: bool) -> Self {
2110        let lines: Vec<&str> = txt.lines().collect();
2111        Self {
2112            lines,
2113            txt_base: txt.as_ptr() as usize,
2114            txt_len: txt.len(),
2115            pos: 0,
2116            n: 0,
2117            m: 0,
2118            num_obj: 0,
2119            n_funcs: 0,
2120            nl_counts: None,
2121            n_discrete: None,
2122            declared_jac_nnz: None,
2123            ampl_options: Vec::new(),
2124            cses: Vec::new(),
2125            quad_enabled,
2126            cse_quad: Vec::new(),
2127            cse_sum_ok: Vec::new(),
2128            cse_mono_ok: Vec::new(),
2129            cse_vars: Vec::new(),
2130            cse_depth: Vec::new(),
2131            cse_depth_memo: std::collections::HashMap::new(),
2132            cse_index: std::collections::HashMap::new(),
2133            parse_depth: 0,
2134            max_depth: parse_depth_limit(),
2135        }
2136    }
2137
2138    /// Byte offset in the source text of the start of line `line`, or the
2139    /// end of the text when the cursor has run off it.
2140    ///
2141    /// Pointer arithmetic against the same allocation, never a deref: every
2142    /// line in `lines` borrows from the source buffer, so the difference is
2143    /// its offset.
2144    fn byte_at(&self, line: usize) -> usize {
2145        match self.lines.get(line) {
2146            Some(l) => l.as_ptr() as usize - self.txt_base,
2147            None => self.txt_len,
2148        }
2149    }
2150
2151    fn next_line(&mut self) -> Option<&'a str> {
2152        while self.pos < self.lines.len() {
2153            let l = self.lines[self.pos];
2154            self.pos += 1;
2155            // Strip comment after '#' for header / data lines (but
2156            // leave the segment-tag tokens untouched — they are the
2157            // first token on the line).
2158            let trimmed = strip_comment(l).trim();
2159            if !trimmed.is_empty() {
2160                return Some(l);
2161            }
2162        }
2163        None
2164    }
2165
2166    /// Next non-blank line, comment stripped and trimmed.
2167    ///
2168    /// Borrows from the source text rather than allocating: the result
2169    /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
2170    /// outlives the `&mut self` this took. A large `.nl` is mostly data
2171    /// lines — 620k of them for a 20k-variable model — and returning an
2172    /// owned `String` put one heap allocation on every single one.
2173    fn next_data_line(&mut self) -> Result<&'a str, String> {
2174        while self.pos < self.lines.len() {
2175            let l = self.lines[self.pos];
2176            self.pos += 1;
2177            let trimmed = strip_comment(l).trim();
2178            if !trimmed.is_empty() {
2179                return Ok(trimmed);
2180            }
2181        }
2182        Err("unexpected end of file in data line".to_string())
2183    }
2184
2185    fn parse_header(&mut self) -> Result<(), String> {
2186        let line0 = self.next_line().ok_or("empty .nl file")?;
2187        let trimmed = strip_comment(line0).trim();
2188        let first = trimmed.chars().next().ok_or("empty header line")?;
2189        if first != 'g' {
2190            return Err(format!(
2191                "only ASCII (g-) .nl files supported; got header '{trimmed}'"
2192            ));
2193        }
2194        // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
2195        // `g` say how many AMPL option words follow on the same line.
2196        // A solver echoes them back in the `.sol` `Options` block, so
2197        // keep them verbatim. Malformed or truncated option lists are not
2198        // fatal — the writer falls back to a generic block.
2199        let mut words = trimmed.split_whitespace();
2200        let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
2201        let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
2202        if opts.len() >= n_opts {
2203            self.ampl_options = opts[..n_opts].to_vec();
2204        }
2205
2206        // Header line 2: n_vars n_cons n_objs ranges eqns
2207        let l2 = self.next_data_line()?;
2208        let nums: Vec<&str> = l2.split_whitespace().collect();
2209        if nums.len() < 3 {
2210            return Err(format!("malformed line 2: '{l2}'"));
2211        }
2212        self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
2213        self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
2214        self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
2215
2216        // Header line 3: `nlc nlo`. Line 4: the network-constraint census,
2217        // which pounce has no use for. Line 5: `nlvc nlvo nlvb`. Together
2218        // these are the model's nonlinearity census — see [`NlCounts`].
2219        //
2220        // A header that does not carry them in the documented shape leaves
2221        // `nl_counts` at `None` rather than at a guess: every consumer has a
2222        // walk-the-trees fallback, and a fabricated count is worse than an
2223        // absent one. The rest of the header stays tolerant in the same way
2224        // the `nfunc` read below is.
2225        let l3 = self.next_data_line()?;
2226        let _l4_network = self.next_data_line()?;
2227        let l5 = self.next_data_line()?;
2228        self.nl_counts = parse_nl_counts(l3, l5);
2229        // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
2230        let l6 = self.next_data_line()?;
2231        let nums5: Vec<&str> = l6.split_whitespace().collect();
2232        self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
2233        // Lines 6..10 are metadata we mostly don't need. Line 7 is the
2234        // discrete-variable census; line 8 is `nzc nzo` — the Jacobian and
2235        // objective-gradient nonzero counts, kept because `nzc` is what
2236        // catches a file truncated before its `J` segments (gh#785); lines
2237        // 9 and 10 are the maximum name lengths and the common-expression
2238        // census.
2239        let l7_discrete = self.next_data_line()?;
2240        {
2241            let v: Vec<usize> = l7_discrete
2242                .split_whitespace()
2243                .map_while(|w| w.parse::<usize>().ok())
2244                .collect();
2245            if v.len() >= 2 {
2246                self.n_discrete = Some(DiscreteCensus {
2247                    binary: v[0],
2248                    integer: v[1],
2249                    // `nlvbi nlvci nlvoi`; absent in a short header.
2250                    nl_both: v.get(2).copied().unwrap_or(0),
2251                    nl_cons: v.get(3).copied().unwrap_or(0),
2252                    nl_objs: v.get(4).copied().unwrap_or(0),
2253                });
2254            }
2255        }
2256        let l8 = self.next_data_line()?;
2257        // Tolerant like the `nfunc` read above: a header that does not
2258        // carry the count in the documented shape leaves it `None`, and
2259        // the cross-check that reads it is skipped rather than fabricated.
2260        self.declared_jac_nnz = l8.split_whitespace().next().and_then(|s| s.parse().ok());
2261        let _l9_name_lens = self.next_data_line()?;
2262        let _l10_common_exprs = self.next_data_line()?;
2263        Ok(())
2264    }
2265
2266    fn peek_segment_line(&mut self) -> Option<&'a str> {
2267        let saved = self.pos;
2268        let l = self.next_line()?;
2269        self.pos = saved;
2270        Some(l)
2271    }
2272
2273    /// Eat the next non-blank line as a segment header. Returns the
2274    /// whole header (after stripping comments) and the comment text.
2275    fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
2276        let raw = self
2277            .next_line()
2278            .ok_or_else(|| "expected segment header".to_string())?;
2279        let (hdr, comment) = split_comment(raw);
2280        Ok((hdr.trim(), comment.trim()))
2281    }
2282
2283    /// Parse one `C`/`O` body, recognizing it from the token stream when
2284    /// that is possible instead of building its tree (gh #588, Q5).
2285    ///
2286    /// The cursor is the whole mechanism. `Parser` is line-based, so a
2287    /// failed recognition costs one assignment to rewind — the same trick
2288    /// `parse_funcall_arg` already uses to route a Hollerith literal — and
2289    /// the fallback then produces exactly the tree this file has always
2290    /// produced. Nothing downstream can tell which arm ran except by
2291    /// measuring memory.
2292    ///
2293    /// Only **degree-2** forms are kept. A body that recognizes as constant
2294    /// or linear is re-parsed as a tree and stored as one: the constant-row
2295    /// fold (gh #492), the linearity contract and the classifier's LP fast
2296    /// path all key on the identity zero and on trees, they are cheap
2297    /// already, and the memory that motivates this phase is entirely in
2298    /// degree-2 rows. Refusing to touch them keeps the change confined to
2299    /// the rows it is for.
2300    fn parse_body(&mut self) -> Result<NlBody, String> {
2301        if !self.quad_enabled {
2302            let e = self.parse_expr()?;
2303            self.check_effective_depth(&e)?;
2304            return Ok(NlBody::Tree(e));
2305        }
2306        let saved = self.pos;
2307        if let Some((form, vars, depth)) = self.parse_expr_quadratic() {
2308            if !form.quadratic().is_empty() {
2309                let src = self.byte_at(saved)..self.byte_at(self.pos);
2310                return Ok(NlBody::Quad(Box::new(QuadBody {
2311                    form,
2312                    vars,
2313                    src,
2314                    depth,
2315                })));
2316            }
2317        }
2318        self.pos = saved;
2319        let e = self.parse_expr()?;
2320        self.check_effective_depth(&e)?;
2321        Ok(NlBody::Tree(e))
2322    }
2323
2324    /// gh#986 review: refuse a tree whose depth *through the defined
2325    /// variables it references* exceeds the guard. `parse_expr` counts only
2326    /// the syntactic nesting of the text it reads, and a `v<i>` reference to a
2327    /// `V` segment is one token there; a chain of `V` segments each referencing
2328    /// the previous is shallow to the parser and as deep as the chain to every
2329    /// pass that walks through a `Cse` (the tape builder, `collect_vars`, drop
2330    /// glue), so the stack the guard exists to protect was unguarded.
2331    fn check_effective_depth(&self, e: &Expr) -> Result<u32, String> {
2332        let d = expr_tree_depth(e, &self.cse_depth_memo);
2333        if d > self.max_depth {
2334            return Err(format!(
2335                "the model nests an expression {d} levels deep counting the defined \
2336                 variables (V segments) it references, more than the {} levels the \
2337                 recursive reader accepts; a chain of defined variables each \
2338                 referencing the previous counts once per link",
2339                self.max_depth
2340            ));
2341        }
2342        Ok(d)
2343    }
2344
2345    /// Read one expression off the token stream as a degree-≤2 form,
2346    /// **without building it**, or give up.
2347    ///
2348    /// This is the same computation as
2349    /// `is_expanded_quadratic(e) && recognize_expr(e)` run on the tree these
2350    /// tokens parse to — the accuracy gate and the algebra, interleaved so
2351    /// neither needs the tree. That equivalence is the phase's correctness
2352    /// claim and is asserted directly, bit for bit, over every body of every
2353    /// `.nl` file in the repository by
2354    /// `pounce-cli/tests/quad_parse_differential.rs`.
2355    ///
2356    /// Two things it must reproduce and not merely approximate:
2357    ///
2358    /// * **The exactness rule.** `is_expanded_quadratic` admits a body only
2359    ///   when reading it as `½xᵀHx + aᵀx + c` repeats the additions the
2360    ///   writer already wrote — expanding a *factored* form cancels, which
2361    ///   is what took `airport.nl` from 16 to 300 iterations in Q4. Here
2362    ///   that rule is structural rather than a separate pass: `+`/`-` are
2363    ///   the spine, everything under a `*`, `/` or `^` is read in monomial
2364    ///   mode, and a `+` seen in monomial mode gives up.
2365    /// * **The association.** `recognize_expr` folds an `o54` sumlist from
2366    ///   the *first* operand to the last, which is also the order the AD
2367    ///   tape sums in, so this folds the same way — operands arrive here in
2368    ///   file order and are folded as they arrive. Summation order is not
2369    ///   observable on distinct monomials and is exactly observable on
2370    ///   repeated ones.
2371    ///
2372    /// On `None` the cursor is left wherever it stopped; the caller rewinds.
2373    fn parse_expr_quadratic(&mut self) -> Option<(Quad2, Vec<u32>, u32)> {
2374        let mut frames: Vec<QFrame> = Vec::new();
2375        let mut vals: Vec<Quad2> = Vec::new();
2376        let mut vars: Vec<u32> = Vec::new();
2377        // Deepest node of the tree these tokens describe. A leaf sits under
2378        // `frames.len()` operators, and a CSE reference carries its body's
2379        // depth under it — the same convention `pounce-py`'s `expr_depth`
2380        // uses, because that is who reads the answer.
2381        let mut depth: u32 = 0;
2382        let note_leaf = |frames: &[QFrame], depth: &mut u32, below: u32| {
2383            let d = u32::try_from(frames.len()).unwrap_or(u32::MAX);
2384            *depth = (*depth).max(d.saturating_add(1).saturating_add(below));
2385        };
2386
2387        loop {
2388            let mono = frames.last().is_some_and(|f| f.mono);
2389            // A `^` base must be atomic — `Const`, `Var` or a `Neg` — or the
2390            // body is a factored form dressed as a monomial. `is_monomial`
2391            // applies the same test to `Pow`'s left operand.
2392            let pow_base = matches!(
2393                frames.last(),
2394                Some(QFrame {
2395                    op: QOp::Pow,
2396                    remaining: 2,
2397                    ..
2398                }) | Some(QFrame {
2399                    op: QOp::Square,
2400                    remaining: 1,
2401                    ..
2402                })
2403            );
2404
2405            let raw = self.next_line()?;
2406            let tok = strip_comment(raw).trim();
2407            let first = tok.chars().next()?;
2408            match first {
2409                'n' => {
2410                    // A constant base is atomic; `is_monomial` accepts it.
2411                    // A non-finite literal bails out of the fast path so the
2412                    // general parser reaches it and reports the error rather
2413                    // than folding it into a quadratic (gh #847).
2414                    let v: Number = tok[1..]
2415                        .trim()
2416                        .parse()
2417                        .ok()
2418                        .filter(|v: &Number| v.is_finite())?;
2419                    note_leaf(&frames, &mut depth, 0);
2420                    vals.push(Quad2::of_constant(v));
2421                }
2422                'v' => {
2423                    let i: usize = tok[1..].trim().parse().ok()?;
2424                    if i < self.n {
2425                        note_leaf(&frames, &mut depth, 0);
2426                        vars.push(u32::try_from(i).ok()?);
2427                        vals.push(Quad2::of_var(i));
2428                    } else {
2429                        // A CSE reference lowers to `Expr::Cse`, which is
2430                        // *not* one of the shapes `is_monomial` accepts as a
2431                        // power's base.
2432                        if pow_base {
2433                            return None;
2434                        }
2435                        let local = i.checked_sub(self.n)?;
2436                        let ok = if mono {
2437                            *self.cse_mono_ok.get(local)?
2438                        } else {
2439                            *self.cse_sum_ok.get(local)?
2440                        };
2441                        if !ok {
2442                            return None;
2443                        }
2444                        let form = self.cse_quad.get(local)?.clone()?;
2445                        note_leaf(&frames, &mut depth, *self.cse_depth.get(local)?);
2446                        vars.extend_from_slice(self.cse_vars.get(local)?);
2447                        vals.push(form);
2448                    }
2449                }
2450                'o' => {
2451                    let code: i32 = tok[1..].trim().parse().ok()?;
2452                    if pow_base && code != 16 {
2453                        return None;
2454                    }
2455                    let (op, arity, child_mono) = match code {
2456                        // The sum spine. Inside a monomial there is no such
2457                        // thing, and the body is a factored form.
2458                        0 if !mono => (QOp::Add, 2, false),
2459                        1 if !mono => (QOp::Sub, 2, false),
2460                        16 => (QOp::Neg, 1, mono),
2461                        54 if !mono => {
2462                            let count_line = self.next_data_line().ok()?;
2463                            let count: usize =
2464                                count_line.split_whitespace().next()?.parse().ok()?;
2465                            (QOp::Sum(count), count, false)
2466                        }
2467                        // Monomial operators: everything below them is read
2468                        // in monomial mode.
2469                        2 => (QOp::Mul, 2, true),
2470                        3 => (QOp::Div, 2, true),
2471                        5 | 81 | 83 => (QOp::Pow, 2, true),
2472                        82 => (QOp::Square, 1, true),
2473                        // Transcendentals, comparisons, conditionals,
2474                        // min/max lists, and `+`/`-` under a monomial.
2475                        _ => return None,
2476                    };
2477                    if arity == 0 {
2478                        // An empty `o54` is the empty sum: zero.
2479                        note_leaf(&frames, &mut depth, 0);
2480                        vals.push(Quad2::default());
2481                    } else {
2482                        frames.push(QFrame {
2483                            op,
2484                            remaining: arity,
2485                            mono: child_mono,
2486                        });
2487                        continue;
2488                    }
2489                }
2490                // `f` (imported function call), `h`, and anything else.
2491                _ => return None,
2492            }
2493
2494            // A value has just been produced. Close every frame it completes.
2495            while let Some(f) = frames.last_mut() {
2496                f.remaining -= 1;
2497                if f.remaining > 0 {
2498                    break;
2499                }
2500                let f = frames.pop()?;
2501                let combined = apply_quad_op(f.op, &mut vals)?;
2502                vals.push(combined);
2503            }
2504            if frames.is_empty() {
2505                break;
2506            }
2507        }
2508
2509        if vals.len() != 1 {
2510            return None;
2511        }
2512        vars.sort_unstable();
2513        vars.dedup();
2514        Some((vals.pop()?, vars, depth))
2515    }
2516
2517    fn parse_expr(&mut self) -> Result<Expr, String> {
2518        // gh#986: a recursion guard that fires before the stack does. A
2519        // stack overflow is an abort, not an error return, so the reader
2520        // refuses past `MAX_PARSE_DEPTH` levels with a clean message at any
2521        // input depth instead of relying on the caller's stack size.
2522        if self.parse_depth >= self.max_depth {
2523            return Err(format!(
2524                "the model nests an expression more than {} levels deep, which the \
2525                 recursive reader refuses rather than overflow the stack (a long \
2526                 left-deep `o0` sum is read flat and does not count; deep unary or \
2527                 `o2`/`o3` nesting does)",
2528                self.max_depth
2529            ));
2530        }
2531        self.parse_depth += 1;
2532        let r = self.parse_expr_inner();
2533        self.parse_depth -= 1;
2534        r
2535    }
2536
2537    fn parse_expr_inner(&mut self) -> Result<Expr, String> {
2538        let raw = self
2539            .next_line()
2540            .ok_or_else(|| "expected expression token".to_string())?;
2541        // Borrowed, not owned: this runs once per node of every
2542        // expression tree in the file, so an owned `String` here is one
2543        // heap allocation per tape op in the whole model.
2544        let tok = strip_comment(raw).trim();
2545        if tok.is_empty() {
2546            return Err("empty expression token".into());
2547        }
2548        let first = tok.chars().next().ok_or("empty expression token")?;
2549        match first {
2550            'n' => {
2551                let v: Number = tok[1..]
2552                    .trim()
2553                    .parse()
2554                    .map_err(|e| format!("n value: {e}"))?;
2555                // A `nan` literal in the objective body reached the answer
2556                // itself: `Objective: nan` under `Solve_Succeeded` and exit
2557                // code 0 (gh #847).
2558                Ok(Expr::Const(finite_or_err("numeric literal", v)?))
2559            }
2560            'v' => {
2561                let i: usize = tok[1..]
2562                    .trim()
2563                    .parse()
2564                    .map_err(|e| format!("v index: {e}"))?;
2565                Ok(self.var_or_cse(i)?)
2566            }
2567            'o' => {
2568                let code: i32 = tok[1..]
2569                    .trim()
2570                    .parse()
2571                    .map_err(|e| format!("opcode: {e}"))?;
2572                self.parse_opcode(code)
2573            }
2574            'f' => {
2575                // AMPL imported (external) function call: `f<id> <nargs>`
2576                // followed by nargs child expressions (or string literals).
2577                let rest = &tok[1..];
2578                let mut parts = rest.split_whitespace();
2579                let id_str = parts
2580                    .next()
2581                    .ok_or_else(|| format!("missing function id in '{tok}'"))?;
2582                let nargs_str = parts
2583                    .next()
2584                    .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
2585                let id: usize = id_str
2586                    .parse()
2587                    .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
2588                let nargs: usize = nargs_str
2589                    .parse()
2590                    .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
2591                let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
2592                for _ in 0..nargs {
2593                    args.push(self.parse_funcall_arg()?);
2594                }
2595                Ok(Expr::Funcall { id, args })
2596            }
2597            't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
2598            other => Err(format!(
2599                "unexpected expression token start '{other}': '{tok}'"
2600            )),
2601        }
2602    }
2603
2604    /// Parse one argument to an AMPL imported function. An argument
2605    /// is either a normal expression (real-valued) or a string literal
2606    /// in the form `h<len>:<chars>`. AMPL emits string args only when the
2607    /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
2608    /// or a parameters-directory path for IDAES Helmholtz functions).
2609    fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
2610        // Peek the next non-blank line so we can route `h...` differently.
2611        let saved = self.pos;
2612        let raw = self
2613            .next_line()
2614            .ok_or_else(|| "expected funcall argument".to_string())?;
2615        // A string arg is a Hollerith literal `h<len>:<chars>` where the
2616        // chars are *exactly* `<len>` bytes and may legitimately contain
2617        // '#'. We must NOT strip a trailing comment before extracting the
2618        // content (that would truncate e.g. a path like `a#b`), and we
2619        // honor the declared length rather than splitting loosely on ':'.
2620        // Detect the form from the leading non-blank char of the raw line;
2621        // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
2622        let lead = raw.trim_start();
2623        if let Some(after_h) = lead.strip_prefix('h') {
2624            let colon = after_h
2625                .find(':')
2626                .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
2627            let len: usize = after_h[..colon]
2628                .trim()
2629                .parse()
2630                .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
2631            let chars = &after_h[colon + 1..];
2632            if chars.len() < len {
2633                return Err(format!(
2634                    "Hollerith string shorter than declared length {len}: {chars:?}"
2635                ));
2636            }
2637            // Take exactly `len` bytes; anything past it (trailing
2638            // whitespace, a real comment) is not part of the string.
2639            if !chars.is_char_boundary(len) {
2640                return Err(format!(
2641                    "Hollerith length {len} splits a multibyte char in {chars:?}"
2642                ));
2643            }
2644            Ok(FuncallArg::Str(chars[..len].to_string()))
2645        } else {
2646            // Rewind: parse_expr re-consumes the line we just peeked.
2647            self.pos = saved;
2648            Ok(FuncallArg::Real(self.parse_expr()?))
2649        }
2650    }
2651
2652    fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
2653        match code {
2654            0 => {
2655                // gh#986: a left-deep run of binary `o0` — `o0 o0 o0 … a b c d`,
2656                // what AMPL and discopt write for a long sum — is read as one
2657                // n-ary `Sum`, so the tree is one level deep however many terms
2658                // there are. Taken only past `SUM_CHAIN_FLATTEN_MIN` links, so
2659                // an ordinary model keeps exactly the tree it always had.
2660                // Each `o0`'s first child is the next line, so the `k + 1`
2661                // operands that follow the run are, in order, the leaves of
2662                // `((a + b) + c) + d`; `Sum` adds them left to right, the same
2663                // order of operations.
2664                let after_first = self.pos;
2665                let mut k = 1usize;
2666                loop {
2667                    let before = self.pos;
2668                    match self.next_line() {
2669                        Some(l) if strip_comment(l).trim() == "o0" => k += 1,
2670                        _ => {
2671                            self.pos = before;
2672                            break;
2673                        }
2674                    }
2675                }
2676                if k > SUM_CHAIN_FLATTEN_MIN {
2677                    let mut terms = Vec::with_capacity(k + 1);
2678                    for _ in 0..=k {
2679                        terms.push(self.parse_expr()?);
2680                    }
2681                    return Ok(Expr::Sum(terms));
2682                }
2683                self.pos = after_first;
2684                let a = self.parse_expr()?;
2685                let b = self.parse_expr()?;
2686                Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
2687            }
2688            1 => {
2689                let a = self.parse_expr()?;
2690                let b = self.parse_expr()?;
2691                Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
2692            }
2693            2 => {
2694                let a = self.parse_expr()?;
2695                let b = self.parse_expr()?;
2696                Ok(Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b)))
2697            }
2698            3 => {
2699                let a = self.parse_expr()?;
2700                let b = self.parse_expr()?;
2701                Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
2702            }
2703            5 => {
2704                let a = self.parse_expr()?;
2705                let b = self.parse_expr()?;
2706                Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
2707            }
2708            15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
2709            16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
2710            39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
2711            41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
2712            42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
2713            43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
2714            44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
2715            46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
2716            38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
2717            49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
2718            53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
2719            40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
2720            45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
2721            37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
2722            51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
2723            52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
2724            50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
2725            47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
2726            // atan2(y, x): binary, operand order `y` then `x`.
2727            48 => {
2728                let a = self.parse_expr()?;
2729                let b = self.parse_expr()?;
2730                Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
2731            }
2732            // Relational comparisons (binary). Operand order is
2733            // `left OP right`.
2734            22 => self.parse_compare(CmpOp::Lt),
2735            23 => self.parse_compare(CmpOp::Le),
2736            24 => self.parse_compare(CmpOp::Eq),
2737            28 => self.parse_compare(CmpOp::Ge),
2738            29 => self.parse_compare(CmpOp::Gt),
2739            30 => self.parse_compare(CmpOp::Ne),
2740            // Logical connectives.
2741            20 => {
2742                let a = self.parse_expr()?;
2743                let b = self.parse_expr()?;
2744                Ok(Expr::Or(Box::new(a), Box::new(b)))
2745            }
2746            21 => {
2747                let a = self.parse_expr()?;
2748                let b = self.parse_expr()?;
2749                Ok(Expr::And(Box::new(a), Box::new(b)))
2750            }
2751            34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
2752            // if-then-else: condition, then-value, else-value.
2753            35 => {
2754                let cond = self.parse_expr()?;
2755                let then_ = self.parse_expr()?;
2756                let else_ = self.parse_expr()?;
2757                Ok(Expr::Cond {
2758                    cond: Box::new(cond),
2759                    then_: Box::new(then_),
2760                    else_: Box::new(else_),
2761                })
2762            }
2763            54 => {
2764                // Variadic sum: next data line gives the count.
2765                let count_line = self.next_data_line()?;
2766                let count: usize = count_line
2767                    .split_whitespace()
2768                    .next()
2769                    .ok_or_else(|| "missing variadic count".to_string())?
2770                    .parse()
2771                    .map_err(|e| format!("variadic count: {e}"))?;
2772                let mut args = Vec::with_capacity(count);
2773                for _ in 0..count {
2774                    args.push(self.parse_expr()?);
2775                }
2776                Ok(Expr::Sum(args))
2777            }
2778            // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
2779            // a count data line followed by that many operands.
2780            11 | 12 => {
2781                let count_line = self.next_data_line()?;
2782                let count: usize = count_line
2783                    .split_whitespace()
2784                    .next()
2785                    .ok_or_else(|| "missing min/max list count".to_string())?
2786                    .parse()
2787                    .map_err(|e| format!("min/max list count: {e}"))?;
2788                let mut args = Vec::with_capacity(count);
2789                for _ in 0..count {
2790                    args.push(self.parse_expr()?);
2791                }
2792                if code == 11 {
2793                    Ok(Expr::MinList(args))
2794                } else {
2795                    Ok(Expr::MaxList(args))
2796                }
2797            }
2798            // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
2799            // emits these in place of the general `o5` (OPPOW) as a hint that
2800            // one operand is constant. The distinction exists because an
2801            // integer / half-integer constant power is evaluated by a
2802            // mul/sqrt chain that stays real for a negative base, whereas the
2803            // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
2804            // they read exactly like `o5`, so they lower to the same `Pow` AST
2805            // and reuse the existing constant-power tape lowering (see
2806            // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
2807            // against the ASL reader and the `ampl/mp` opcode table:
2808            // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
2809            // unary with an implicit exponent of 2.
2810            //
2811            // o81 OP1POW: `base ^ (const exponent)` — binary, operands
2812            // `base` then `exp` (the exponent is a numeric node here).
2813            81 => {
2814                let base = self.parse_expr()?;
2815                let exp = self.parse_expr()?;
2816                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2817            }
2818            // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
2819            82 => {
2820                let base = self.parse_expr()?;
2821                Ok(Expr::Binary(
2822                    BinOp::Pow,
2823                    Box::new(base),
2824                    Box::new(Expr::Const(2.0)),
2825                ))
2826            }
2827            // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
2828            // (the numeric node) then `exp`.
2829            83 => {
2830                let base = self.parse_expr()?;
2831                let exp = self.parse_expr()?;
2832                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2833            }
2834            other => Err(format!("unsupported opcode o{other}")),
2835        }
2836    }
2837
2838    /// Parse the two operands of a relational opcode into an
2839    /// [`Expr::Compare`]. Operand order is `left OP right`.
2840    fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
2841        let a = self.parse_expr()?;
2842        let b = self.parse_expr()?;
2843        Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
2844    }
2845
2846    /// Resolve a `v<i>` token into either a plain variable reference
2847    /// (`i < n`) or a shared CSE reference (`i >= n`).
2848    fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
2849        if i < self.n {
2850            Ok(Expr::Var(i))
2851        } else {
2852            let local = i - self.n;
2853            self.cses
2854                .get(local)
2855                .map(|rc| Expr::Cse(rc.clone()))
2856                .ok_or_else(|| {
2857                    format!(
2858                        "v{i} references CSE {local} but only {} have been defined",
2859                        self.cses.len()
2860                    )
2861                })
2862        }
2863    }
2864
2865    /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
2866    /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
2867    /// CSEs are numbered starting at `n` and must appear in order.
2868    fn parse_v_segment(&mut self) -> Result<(), String> {
2869        let (hdr, _) = self.eat_segment_header()?;
2870        let parts: Vec<&str> = hdr.split_whitespace().collect();
2871        if parts.len() < 2 {
2872            return Err(format!("malformed V-segment header: {hdr}"));
2873        }
2874        let cse_idx = parse_segment_index(parts[0], 'V')?;
2875        let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
2876        // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
2877        let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
2878        for _ in 0..nlin {
2879            let line = self.next_data_line()?;
2880            let (var, coef) = parse_var_coef(line)?;
2881            linear.push((var, coef));
2882        }
2883        let nonlin = self.parse_expr()?;
2884        // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
2885        // reference earlier CSEs as well as plain variables.
2886        let mut combined = nonlin;
2887        for (var, coef) in linear {
2888            let v_expr = self.var_or_cse(var)?;
2889            let term = if coef == 1.0 {
2890                v_expr
2891            } else {
2892                Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
2893            };
2894            combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
2895        }
2896        if cse_idx < self.n {
2897            return Err(format!("V{cse_idx} below n={}", self.n));
2898        }
2899        let local = cse_idx - self.n;
2900        if local != self.cses.len() {
2901            return Err(format!(
2902                "V-segment index V{cse_idx} out of order; expected V{}",
2903                self.n + self.cses.len()
2904            ));
2905        }
2906        // What a reference to this body would mean to the streaming
2907        // recognizer, answered once here rather than per reference. The
2908        // `V` tree exists at this point, so these are the *same* functions
2909        // `NlTnlp` applies to a whole row — the parse-time recognizer never
2910        // gets a second opinion about a CSE.
2911        // gh#986 review: bound the chain before any pass walks through it.
2912        let depth = self.check_effective_depth(&combined)?;
2913        if self.quad_enabled {
2914            // gh#986 review: every earlier body's verdicts are already
2915            // known, so each pass reads them instead of re-walking the chain
2916            // of defined variables below this one (quadratic over a long
2917            // chain otherwise).
2918            let idx = &self.cse_index;
2919            let (quad, sum_ok, mono_ok) = (&self.cse_quad, &self.cse_sum_ok, &self.cse_mono_ok);
2920            let quad_seed = |k: *const Expr| idx.get(&k).map(|&i| quad[i].clone());
2921            let shape_seed = |k: *const Expr, mono: bool| {
2922                idx.get(&k)
2923                    .map(|&i| if mono { mono_ok[i] } else { sum_ok[i] })
2924            };
2925            let q = crate::nl_quadratic::recognize_expr_seeded(&combined, &quad_seed);
2926            let s_ok = crate::nl_quadratic::is_expanded_quadratic_seeded(&combined, &shape_seed);
2927            let m_ok = crate::nl_quadratic::is_monomial_expr_seeded(&combined, &shape_seed);
2928            let mut vars: BTreeSet<usize> = BTreeSet::new();
2929            collect_vars_seeded(&combined, &mut vars, &self.cse_index, &self.cse_vars);
2930            self.cse_quad.push(q);
2931            self.cse_sum_ok.push(s_ok);
2932            self.cse_mono_ok.push(m_ok);
2933            self.cse_vars
2934                .push(vars.into_iter().map(|v| v as u32).collect());
2935            self.cse_depth.push(depth);
2936        }
2937        let body = Arc::new(combined);
2938        self.cse_index.insert(Arc::as_ptr(&body), self.cses.len());
2939        self.cse_depth_memo.insert(Arc::as_ptr(&body), depth);
2940        self.cses.push(body);
2941        Ok(())
2942    }
2943}
2944
2945/// Combine the operands of one recognized operator, mirroring
2946/// [`crate::nl_quadratic::recognize_expr`]'s `Apply` arm term for term.
2947///
2948/// Every difference from that function is a difference in the coefficients
2949/// this parser stores, so there is nothing here that is "equivalent but
2950/// tidier": the division scales by the reciprocal because that is what the
2951/// tree walk does, and the sumlist folds back to front for the same reason.
2952/// Nesting depth of a `V`-segment body, leaf = 1, a `Cse` reference
2953/// counting one level above its body — the convention `pounce-py`'s
2954/// `expr_depth` uses, since that is the guard the answer feeds.
2955///
2956/// Recursive, and safe to be: it recurses only through the syntax of `e`,
2957/// which [`Parser::parse_expr`] has just built *recursively* on this same
2958/// stack, so a frame that fits the parser fits this. A `Cse` reference is
2959/// read from `memo` (every `V` body is memoized when its segment is parsed,
2960/// and a body can only reference earlier ones), so the walk never descends a
2961/// chain of defined variables: linear in `e`, where it used to be linear in
2962/// the whole chain on every `V` segment -- quadratic over a long chain, and
2963/// as deep as it (gh#986 review).
2964fn expr_tree_depth(e: &Expr, memo: &std::collections::HashMap<*const Expr, u32>) -> u32 {
2965    let deepest = |kids: &mut dyn Iterator<Item = &Expr>| {
2966        kids.fold(0u32, |acc, k| acc.max(expr_tree_depth(k, memo)))
2967    };
2968    1 + match e {
2969        Expr::Const(_) | Expr::Var(_) => 0,
2970        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
2971            deepest(&mut [&**a, &**b].into_iter())
2972        }
2973        Expr::Unary(_, a) | Expr::Not(a) => expr_tree_depth(a, memo),
2974        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => deepest(&mut args.iter()),
2975        Expr::Cond { cond, then_, else_ } => {
2976            deepest(&mut [&**cond, &**then_, &**else_].into_iter())
2977        }
2978        Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
2979            FuncallArg::Real(inner) => Some(inner),
2980            FuncallArg::Str(_) => None,
2981        })),
2982        Expr::Cse(body) => match memo.get(&Arc::as_ptr(body)) {
2983            Some(&d) => d,
2984            None => expr_tree_depth(body, memo),
2985        },
2986    }
2987}
2988
2989fn apply_quad_op(op: QOp, vals: &mut Vec<Quad2>) -> Option<Quad2> {
2990    let pop2 = |vals: &mut Vec<Quad2>| -> Option<(Quad2, Quad2)> {
2991        let b = vals.pop()?;
2992        let a = vals.pop()?;
2993        Some((a, b))
2994    };
2995    Some(match op {
2996        QOp::Sum(n) => {
2997            let at = vals.len().checked_sub(n)?;
2998            let mut acc = Quad2::default();
2999            // Operands arrive in file order, so `drain` already yields them
3000            // front to back — the order `recognize_expr` folds in, and the
3001            // order the AD tape sums in. Do not reverse this: floating-point
3002            // addition is not associative, and on repeated monomials the two
3003            // orders do not agree bit for bit.
3004            for p in vals.drain(at..) {
3005                acc = Quad2::add(acc, p);
3006            }
3007            acc
3008        }
3009        QOp::Neg => vals.pop()?.neg(),
3010        QOp::Add => {
3011            let (a, b) = pop2(vals)?;
3012            Quad2::add(a, b)
3013        }
3014        QOp::Sub => {
3015            let (a, b) = pop2(vals)?;
3016            Quad2::add(a, b.neg())
3017        }
3018        QOp::Mul => {
3019            let (a, b) = pop2(vals)?;
3020            a.mul(&b)?
3021        }
3022        QOp::Div => {
3023            let (a, b) = pop2(vals)?;
3024            let d = b.as_constant()?;
3025            if d == 0.0 {
3026                return None;
3027            }
3028            let mut out = a.div_by_constant(d);
3029            out.absorb_flags(&b);
3030            out
3031        }
3032        QOp::Pow => {
3033            let (a, b) = pop2(vals)?;
3034            let exp = b.as_constant()?;
3035            let mut out = if exp == 0.0 {
3036                Quad2::of_constant(1.0)
3037            } else if exp == 1.0 {
3038                a
3039            } else if exp == 2.0 {
3040                a.mul(&a)?
3041            } else {
3042                return None;
3043            };
3044            // The exponent is read out of a form with `as_constant`, which
3045            // leaves its flags behind; `Div` above does the same.
3046            out.absorb_flags(&b);
3047            out
3048        }
3049        // `o82` is `Pow(base, Const(2.0))` with the exponent left implicit,
3050        // so it takes the `exp == 2.0` branch above.
3051        QOp::Square => {
3052            let a = vals.pop()?;
3053            a.mul(&a)?
3054        }
3055    })
3056}
3057
3058/// Re-parse one recognized body from the bytes it was recognized from.
3059///
3060/// The point is that this is the *same* function the original parse ran, on
3061/// the *same* bytes, with the same `n` and the same `V`-segment bodies — so
3062/// the tree it returns is the tree that parse would have built, down to the
3063/// `Arc` identities that `HybridTape::build_multi` keys CSE sharing on.
3064/// Deriving an equivalent tree from the stored coefficients instead would
3065/// be a different tree, evaluated by a different tape, and this phase would
3066/// stop being invisible from outside.
3067fn parse_body_fragment(txt: &str, n: usize, cses: &[Arc<Expr>]) -> Result<Expr, String> {
3068    let mut p = Parser::new(txt, false);
3069    p.n = n;
3070    p.cses = cses.to_vec();
3071    p.parse_expr()
3072}
3073
3074fn strip_comment(s: &str) -> &str {
3075    match s.find('#') {
3076        Some(i) => &s[..i],
3077        None => s,
3078    }
3079}
3080
3081fn split_comment(s: &str) -> (&str, &str) {
3082    match s.find('#') {
3083        Some(i) => (&s[..i], &s[i + 1..]),
3084        None => (s, ""),
3085    }
3086}
3087
3088// --------------------------------------------------------------------
3089// Expression evaluation and gradient (tree walkers, kept for tests).
3090// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
3091// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
3092// --------------------------------------------------------------------
3093
3094/// Forward-mode value evaluation.
3095pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
3096    match e {
3097        Expr::Const(c) => *c,
3098        Expr::Var(i) => x[*i],
3099        Expr::Binary(op, a, b) => {
3100            let va = eval_expr(a, x);
3101            let vb = eval_expr(b, x);
3102            match op {
3103                BinOp::Add => va + vb,
3104                BinOp::Sub => va - vb,
3105                BinOp::Mul => va * vb,
3106                BinOp::Div => va / vb,
3107                BinOp::Pow => va.powf(vb),
3108                BinOp::Atan2 => va.atan2(vb),
3109                BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
3110            }
3111        }
3112        Expr::Unary(op, a) => {
3113            let va = eval_expr(a, x);
3114            match op {
3115                UnaryOp::Neg => -va,
3116                UnaryOp::Sqrt => va.sqrt(),
3117                UnaryOp::Log => va.ln(),
3118                UnaryOp::Log10 => va.log10(),
3119                UnaryOp::Exp => va.exp(),
3120                UnaryOp::Abs => va.abs(),
3121                UnaryOp::Sin => va.sin(),
3122                UnaryOp::Cos => va.cos(),
3123                UnaryOp::Tan => va.tan(),
3124                UnaryOp::Atan => va.atan(),
3125                UnaryOp::Acos => va.acos(),
3126                UnaryOp::Sinh => va.sinh(),
3127                UnaryOp::Cosh => va.cosh(),
3128                UnaryOp::Tanh => va.tanh(),
3129                UnaryOp::Asin => va.asin(),
3130                UnaryOp::Acosh => va.acosh(),
3131                UnaryOp::Asinh => va.asinh(),
3132                UnaryOp::Atanh => va.atanh(),
3133                UnaryOp::Erf => crate::nl_tape::erf(va),
3134                UnaryOp::XLogX => crate::nl_tape::xlogx(va),
3135            }
3136        }
3137        Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
3138        Expr::MinList(args) => args
3139            .iter()
3140            .map(|a| eval_expr(a, x))
3141            .fold(Number::INFINITY, Number::min),
3142        Expr::MaxList(args) => args
3143            .iter()
3144            .map(|a| eval_expr(a, x))
3145            .fold(Number::NEG_INFINITY, Number::max),
3146        Expr::Compare(op, a, b) => {
3147            let va = eval_expr(a, x);
3148            let vb = eval_expr(b, x);
3149            let truth = match op {
3150                CmpOp::Lt => va < vb,
3151                CmpOp::Le => va <= vb,
3152                CmpOp::Eq => va == vb,
3153                CmpOp::Ge => va >= vb,
3154                CmpOp::Gt => va > vb,
3155                CmpOp::Ne => va != vb,
3156            };
3157            if truth { 1.0 } else { 0.0 }
3158        }
3159        Expr::And(a, b) => {
3160            if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
3161                1.0
3162            } else {
3163                0.0
3164            }
3165        }
3166        Expr::Or(a, b) => {
3167            if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
3168                1.0
3169            } else {
3170                0.0
3171            }
3172        }
3173        Expr::Not(a) => {
3174            if eval_expr(a, x) == 0.0 {
3175                1.0
3176            } else {
3177                0.0
3178            }
3179        }
3180        Expr::Cond { cond, then_, else_ } => {
3181            if eval_expr(cond, x) != 0.0 {
3182                eval_expr(then_, x)
3183            } else {
3184                eval_expr(else_, x)
3185            }
3186        }
3187        Expr::Cse(body) => eval_expr(body, x),
3188        Expr::Funcall { .. } => panic!(
3189            "eval_expr: AMPL imported function called without an external resolver; \
3190             evaluate through the tape AD path (Tape::build_with_externals) instead"
3191        ),
3192    }
3193}
3194
3195/// Index of the active operand of an n-ary min (`want_min = true`) or
3196/// max (`want_min = false`) list at point `x`: the smallest / largest
3197/// value, with ties resolved to the first such operand (the
3198/// conventional subgradient choice). Returns `None` for an empty list.
3199fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
3200    let mut best: Option<(usize, Number)> = None;
3201    for (i, a) in args.iter().enumerate() {
3202        let v = eval_expr(a, x);
3203        match best {
3204            None => best = Some((i, v)),
3205            Some((_, bv)) => {
3206                // Strict comparison keeps the FIRST extremal operand on
3207                // ties, matching the subgradient convention used by Abs
3208                // and Select elsewhere in the tape.
3209                if (want_min && v < bv) || (!want_min && v > bv) {
3210                    best = Some((i, v));
3211                }
3212            }
3213        }
3214    }
3215    best.map(|(i, _)| i)
3216}
3217
3218/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
3219pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
3220    match e {
3221        Expr::Const(_) => {}
3222        Expr::Var(i) => grad[*i] += seed,
3223        Expr::Binary(op, a, b) => {
3224            let va = eval_expr(a, x);
3225            let vb = eval_expr(b, x);
3226            match op {
3227                BinOp::Add => {
3228                    grad_expr(a, x, seed, grad);
3229                    grad_expr(b, x, seed, grad);
3230                }
3231                BinOp::Sub => {
3232                    grad_expr(a, x, seed, grad);
3233                    grad_expr(b, x, -seed, grad);
3234                }
3235                BinOp::Mul => {
3236                    grad_expr(a, x, seed * vb, grad);
3237                    grad_expr(b, x, seed * va, grad);
3238                }
3239                BinOp::Div => {
3240                    grad_expr(a, x, seed / vb, grad);
3241                    grad_expr(b, x, -seed * va / (vb * vb), grad);
3242                }
3243                BinOp::Pow => {
3244                    // d/da: b * a^(b-1)
3245                    let dpa = vb * va.powf(vb - 1.0);
3246                    grad_expr(a, x, seed * dpa, grad);
3247                    // d/db: a^b * ln(a) (only valid for a>0; simple branch)
3248                    if va > 0.0 {
3249                        let dpb = va.powf(vb) * va.ln();
3250                        grad_expr(b, x, seed * dpb, grad);
3251                    }
3252                }
3253                BinOp::Atan2 => {
3254                    // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
3255                    let d = va * va + vb * vb;
3256                    grad_expr(a, x, seed * vb / d, grad);
3257                    grad_expr(b, x, -seed * va / d, grad);
3258                }
3259                BinOp::CEntropy => {
3260                    grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
3261                    grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
3262                }
3263            }
3264        }
3265        Expr::Unary(op, a) => {
3266            let va = eval_expr(a, x);
3267            let d = match op {
3268                UnaryOp::Neg => -1.0,
3269                UnaryOp::Sqrt => 0.5 / va.sqrt(),
3270                UnaryOp::Log => 1.0 / va,
3271                UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
3272                UnaryOp::Exp => va.exp(),
3273                UnaryOp::Abs => {
3274                    if va > 0.0 {
3275                        1.0
3276                    } else if va < 0.0 {
3277                        -1.0
3278                    } else {
3279                        0.0
3280                    }
3281                }
3282                UnaryOp::Sin => va.cos(),
3283                UnaryOp::Cos => -va.sin(),
3284                UnaryOp::Tan => {
3285                    let t = va.tan();
3286                    1.0 + t * t
3287                }
3288                UnaryOp::Atan => 1.0 / (1.0 + va * va),
3289                UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
3290                UnaryOp::Sinh => va.cosh(),
3291                UnaryOp::Cosh => va.sinh(),
3292                UnaryOp::Tanh => {
3293                    let t = va.tanh();
3294                    1.0 - t * t
3295                }
3296                UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
3297                UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
3298                UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
3299                UnaryOp::Atanh => 1.0 / (1.0 - va * va),
3300                UnaryOp::Erf => crate::nl_tape::erf_d1(va),
3301                UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
3302            };
3303            grad_expr(a, x, seed * d, grad);
3304        }
3305        Expr::Sum(args) => {
3306            for arg in args {
3307                grad_expr(arg, x, seed, grad);
3308            }
3309        }
3310        // min/max are piecewise linear: the seed flows only through the
3311        // currently-active (smallest / largest) operand — a subgradient.
3312        // Ties resolve to the first such operand. Empty list: no operand,
3313        // no derivative (matches the ±inf eval fold).
3314        Expr::MinList(args) => {
3315            if let Some(k) = argmin_argmax(args, x, true) {
3316                grad_expr(&args[k], x, seed, grad);
3317            }
3318        }
3319        Expr::MaxList(args) => {
3320            if let Some(k) = argmin_argmax(args, x, false) {
3321                grad_expr(&args[k], x, seed, grad);
3322            }
3323        }
3324        // Comparisons and logical connectives are piecewise constant:
3325        // zero derivative, so no seed propagates into their operands.
3326        Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3327        // if-then-else: differentiate only the active branch. The
3328        // branch-switch discontinuity contributes no derivative.
3329        Expr::Cond { cond, then_, else_ } => {
3330            if eval_expr(cond, x) != 0.0 {
3331                grad_expr(then_, x, seed, grad);
3332            } else {
3333                grad_expr(else_, x, seed, grad);
3334            }
3335        }
3336        Expr::Cse(body) => grad_expr(body, x, seed, grad),
3337        Expr::Funcall { .. } => {
3338            panic!("grad_expr: AMPL imported function called without an external resolver")
3339        }
3340    }
3341}
3342
3343/// Walk `e` and insert every `Var(i)` index into `out`.
3344///
3345/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3346/// identity. Without that this is Θ(2^depth) on a DAG that shares
3347/// subexpressions — each reference re-walks the whole body — and presolve
3348/// calls this on every solve (`get_variables_linearity`). Skipping a
3349/// repeat visit cannot change the answer: `out` is a set, and a second
3350/// walk of the same body inserts exactly the indices the first already did.
3351/// [`collect_vars`] over a `V` body whose earlier `Cse` references already
3352/// have their variable sets in `cse_vars` (indexed through `index`): those are
3353/// read, not re-walked (gh#986 review). The recursion is through `e`'s own
3354/// syntax only, which the parser has just built on this stack.
3355fn collect_vars_seeded(
3356    e: &Expr,
3357    out: &mut BTreeSet<usize>,
3358    index: &std::collections::HashMap<*const Expr, usize>,
3359    cse_vars: &[Vec<u32>],
3360) {
3361    let mut kids: Vec<&Expr> = vec![e];
3362    while let Some(e) = kids.pop() {
3363        match e {
3364            Expr::Const(_) => {}
3365            Expr::Var(i) => {
3366                out.insert(*i);
3367            }
3368            Expr::Cse(body) => match index.get(&Arc::as_ptr(body)) {
3369                Some(&k) => out.extend(cse_vars[k].iter().map(|&v| v as usize)),
3370                None => collect_vars(body, out),
3371            },
3372            Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3373                kids.push(a);
3374                kids.push(b);
3375            }
3376            Expr::Unary(_, a) | Expr::Not(a) => kids.push(a),
3377            Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => kids.extend(args.iter()),
3378            Expr::Cond { cond, then_, else_ } => {
3379                kids.push(cond);
3380                kids.push(then_);
3381                kids.push(else_);
3382            }
3383            Expr::Funcall { args, .. } => {
3384                for a in args {
3385                    if let FuncallArg::Real(e) = a {
3386                        kids.push(e);
3387                    }
3388                }
3389            }
3390        }
3391    }
3392}
3393
3394pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3395    // `HashSet::new` does not allocate until the first insert, so an
3396    // expression with no CSEs pays nothing for the memo.
3397    let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3398    collect_vars_memo(e, out, &mut seen);
3399}
3400
3401fn collect_vars_memo(
3402    e: &Expr,
3403    out: &mut BTreeSet<usize>,
3404    seen: &mut std::collections::HashSet<*const Expr>,
3405) {
3406    match e {
3407        Expr::Const(_) => {}
3408        Expr::Var(i) => {
3409            out.insert(*i);
3410        }
3411        Expr::Binary(_, a, b) => {
3412            collect_vars_memo(a, out, seen);
3413            collect_vars_memo(b, out, seen);
3414        }
3415        Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3416        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3417            for a in args {
3418                collect_vars_memo(a, out, seen);
3419            }
3420        }
3421        // Collect from every child, including the condition: even
3422        // though the comparison/branch-test contributes no derivative,
3423        // the variables it reads are genuinely "used" by the problem,
3424        // and being conservative here only ever adds structural zeros
3425        // to the Jacobian/Hessian (never drops a real nonzero).
3426        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3427            collect_vars_memo(a, out, seen);
3428            collect_vars_memo(b, out, seen);
3429        }
3430        Expr::Not(a) => collect_vars_memo(a, out, seen),
3431        Expr::Cond { cond, then_, else_ } => {
3432            collect_vars_memo(cond, out, seen);
3433            collect_vars_memo(then_, out, seen);
3434            collect_vars_memo(else_, out, seen);
3435        }
3436        Expr::Cse(body) => {
3437            if seen.insert(Arc::as_ptr(body)) {
3438                collect_vars_memo(body, out, seen);
3439            }
3440        }
3441        Expr::Funcall { args, .. } => {
3442            for a in args {
3443                if let FuncallArg::Real(e) = a {
3444                    collect_vars_memo(e, out, seen);
3445                }
3446            }
3447        }
3448    }
3449}
3450
3451// --------------------------------------------------------------------
3452// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3453// Jacobian, and Hessian. Built once at construction; every solve-time
3454// callback is a tape sweep, no expression-tree recursion.
3455// --------------------------------------------------------------------
3456
3457/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3458/// After a directional Hessian-vector product `compressed = H · s_c`,
3459/// the entry at row `row` came uniquely from column `col` (because
3460/// no two columns of color `c` share any nonzero row), so we
3461/// scatter `compressed[row]` into `values[hess_idx]`.
3462#[derive(Debug, Clone)]
3463struct ColorWrite {
3464    row: u32,
3465    hess_idx: u32,
3466}
3467
3468/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3469/// **shared prelude** holding every CSE body referenced by two or more
3470/// summands, evaluated once per sweep.
3471///
3472/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3473/// defined variable (`V` segment) referenced from many rows is re-emitted —
3474/// and re-evaluated — once per reference. That is invisible on most models
3475/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3476/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3477/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3478/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3479/// search. See pounce#476.
3480///
3481/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3482/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3483/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3484/// overhead the flat tapes do not, so each derivative order has to earn
3485/// its switch.
3486#[derive(Debug, Clone)]
3487struct ConHybrid {
3488    tape: HybridTape,
3489    /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3490    /// Length `m + 1`.
3491    row_start: Vec<usize>,
3492    /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3493    prelude_vals: Vec<f64>,
3494    /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3495    local_vals: Vec<f64>,
3496    /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3497    /// value arenas above. `gradient_summand` zeroes only the slots a
3498    /// summand actually reaches, so these are allocated once and reused.
3499    local_adj: Vec<f64>,
3500    prelude_adj: Vec<f64>,
3501    /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3502    /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3503    /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3504    use_for_jac: bool,
3505    /// Whether `eval_h` routes the constraint block through the shared
3506    /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3507    use_for_hess: bool,
3508    // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3509    // `try_new` after the Hessian coloring exists; cheap enough (a few
3510    // index tables plus one f64 per local op) to build whenever the
3511    // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3512    /// Forward values of every summand, packed at
3513    /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3514    /// fills it; every color then reuses the values, mirroring the flat
3515    /// path's forward-once-per-tape structure.
3516    local_vals_all: Vec<f64>,
3517    /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3518    local_off: Vec<usize>,
3519    /// Constraint row of each summand (the inverse of `row_start`), for
3520    /// the `λ[row]` weight lookup.
3521    summand_row: Vec<u32>,
3522    /// Per color: the summands whose variables fall in that color — the
3523    /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3524    /// exactly the live (color, summand) pairs.
3525    hess_color_summands: Vec<Vec<u32>>,
3526    /// Per color: the prelude slots that color's summands actually reach,
3527    /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3528    /// Both prelude sweeps run once per color, so walking the whole
3529    /// prelude each time would cost `n_colors × |prelude|` where the
3530    /// op-ratio gate assumes `|prelude|`; iterating the union of the
3531    /// color's `prelude_reach` sets makes the cost proportional to what
3532    /// is used, so the gate does not need an `n_colors` term. Stored as
3533    /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3534    /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3535    /// this struct is the one #552 made O(n²) by holding per-color dense
3536    /// arrays.
3537    hess_color_reach: Vec<u32>,
3538    hess_color_reach_off: Vec<usize>,
3539    /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3540    prelude_dot: Vec<f64>,
3541    /// First-/second-order prelude adjoint accumulators. NOT shared
3542    /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3543    /// between-colors invariant (`prelude_reverse_directional`'s
3544    /// consume-and-zero contract), while `gradient_summand` zeroes only
3545    /// the slots it is about to use and leaves them dirty afterwards —
3546    /// sharing the buffer would seed a later `eval_h` with a stale
3547    /// Jacobian adjoint.
3548    hess_prelude_adj: Vec<f64>,
3549    prelude_adj_dot: Vec<f64>,
3550    /// Local tangent / second-order adjoint arenas, sized to
3551    /// `tape.max_summand_ops()`.
3552    local_dot: Vec<f64>,
3553    local_adj_dot: Vec<f64>,
3554}
3555
3556/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3557/// shared-CSE prelude.
3558///
3559/// `eval_g` takes the hybrid path unconditionally because it only needs
3560/// *values*: the prelude is swept once for the whole constraint block and
3561/// the saving is the full op-count ratio. The Jacobian is different. Each
3562/// row needs its own gradient, so only the forward sweep can be shared —
3563/// the reverse sweep still walks each summand's `prelude_reach`
3564/// separately, and it pays a per-op cost the flat tape does not: a nested
3565/// `SummandOp` dispatch and an indirected walk over a reach list instead
3566/// of a straight loop over a contiguous `Vec<TapeOp>`.
3567///
3568/// So the hybrid Jacobian wins only when the shared bodies are large
3569/// enough for the halved forward sweep to outweigh that overhead.
3570/// Measured on chain models at CSE redundancy 40, varying the body size
3571/// (`eval_jac_g`, flat → hybrid):
3572///
3573/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3574/// |---|---|---|---|---|---|---|---|---|
3575/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3576///
3577/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3578/// a model that does not clearly benefit stays on the flat path. For
3579/// reference `robot_a` (#476) measures 4.03×.
3580const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3581
3582/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3583/// block through the shared-CSE prelude (issue #557).
3584///
3585/// The Hessian shares **both** second-order sweeps of the prelude, not just
3586/// the forward one: the coloring hands every summand of a color the same
3587/// seed vector, so the prelude forward tangent runs once per color, and —
3588/// because reverse-over-tangent is linear in its adjoint seeds — the
3589/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3590/// unit-weight prelude reverse sweep per color. That is why its crossover
3591/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3592/// Jacobian can only share the forward half, and per-row gradients forbid
3593/// batching its reverse sweeps at all.
3594///
3595/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3596/// bodies), varying the body size — the same protocol as the Jacobian
3597/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3598/// flat/hybrid pairs per point**, with the observed range, because
3599/// single runs on a shared machine are not reproducible to the precision
3600/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3601/// single ratio:
3602///
3603/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3604/// |---|---|---|---|---|---|---|---|---|
3605/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3606/// | 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 |
3607///
3608/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3609/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3610/// break-even and individual runs lose. Setting it there costs only a
3611/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3612/// bit-identically, so a gate placed too high is merely conservative while
3613/// one placed too low risks a real regression. For reference `robot_a`
3614/// (#476) measures 4.03×.
3615const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3616
3617// `Clone` supports the batched-solve path (pounce#126): one parsed
3618// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3619// the clone is cheap relative to a solve) and each clone gets its own
3620// bound / starting-point overrides via [`NlTnlp::variant`].
3621#[derive(Debug, Clone)]
3622pub struct NlTnlp {
3623    prob: NlProblem,
3624    /// Per-summand objective tapes (one `Tape` per top-level
3625    /// summand after `split_top_sums`).
3626    obj_tapes: Vec<Tape>,
3627    /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3628    /// one `Tape` per summand of constraint `i`.
3629    con_tapes: Vec<Vec<Tape>>,
3630    /// Constraint-block tape with a **shared** CSE prelude, used by
3631    /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3632    /// `eval_g` on the per-summand `con_tapes` above.
3633    con_hybrid: Option<ConHybrid>,
3634    /// The degree-≤2 objective and rows, evaluated from their constant
3635    /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3636    /// has an **empty** `con_tapes` entry, so every loop over the tapes
3637    /// naturally contributes nothing for it and only the sites that consult
3638    /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3639    /// `eval_h`, and `hessian_vector_products` — the last of which the design
3640    /// note's list of five omitted, and which is the one where an omission
3641    /// would be silent. Empty for a model with nothing recognized, in which
3642    /// case everything below behaves bit for bit as it did before this
3643    /// existed.
3644    quad: QuadraticStructure,
3645    /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3646    /// Empty when `quad` is — the pattern is then all tape. The coloring is
3647    /// built over this subset alone, which is what stops one dense quadratic
3648    /// block from forcing the color count to `n` for the benefit of tapes
3649    /// that no longer exist.
3650    h_tape_mask: Vec<bool>,
3651    /// Lower-triangle Hessian sparsity (row >= col), one entry per
3652    /// structurally nonzero second derivative in the Lagrangian.
3653    h_irow: Vec<i32>,
3654    h_jcol: Vec<i32>,
3655    /// Per-row sorted variable indices for the constraint Jacobian.
3656    jac_cols: Vec<Vec<usize>>,
3657    jac_nnz: usize,
3658    /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3659    /// is in color `c`, else `0.0`. Each color is a set of
3660    /// variables whose Hessian columns have pairwise-disjoint
3661    /// nonzero rows; one directional H·s product per color
3662    /// recovers all those columns simultaneously. Dense for
3663    /// O(1) lookup in the per-op forward tangent.
3664    seeds: Vec<Vec<f64>>,
3665    /// Per-color decoding table: for each `(row, hess_idx)` entry,
3666    /// scatter `compressed_c[row] -> values[hess_idx]` after the
3667    /// per-color directional product.
3668    decoding: Vec<Vec<ColorWrite>>,
3669    /// For each objective tape: the distinct colors of vars it
3670    /// references. Lets us skip tape × color pairs where the tape
3671    /// has zero overlap with the color's seed.
3672    obj_tape_colors: Vec<Vec<u32>>,
3673    /// Same as `obj_tape_colors` but per constraint × summand.
3674    con_tape_colors: Vec<Vec<Vec<u32>>>,
3675    /// The same work, color-major, for the parallel flat Hessian path.
3676    hess_color_index: HessColorIndex,
3677    /// Offset of each row's run in the Jacobian `values` array, length
3678    /// `m + 1`. Lets a parallel row group take one contiguous slice.
3679    jac_row_off: Vec<u32>,
3680    /// Per-worker scratch for the parallel Jacobian, kept across evaluations:
3681    /// each holds a length-`n` gradient row, too big to allocate per call.
3682    #[cfg(not(target_arch = "wasm32"))]
3683    jac_scratch_pool: Vec<JacScratch>,
3684    /// Color of each variable's Hessian column, `u32::MAX` for a column
3685    /// that needs no pass of its own. Kept so
3686    /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3687    /// pass in `compressed`.
3688    var_color: Vec<u32>,
3689    /// Columns peeled out of the conflict structure and given singleton
3690    /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3691    peeled_cols: Vec<u32>,
3692    final_x: Option<Vec<Number>>,
3693    final_obj: Number,
3694    /// Converged constraint multipliers (length `m`, original `.nl` row
3695    /// order, user convention), captured from the same `finalize_solution`
3696    /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3697    /// block without re-deriving it from the algorithm's internal `y_c` /
3698    /// `y_d` split and scaling.
3699    final_lambda: Option<Vec<Number>>,
3700    /// Converged bound multipliers (length `n` each, Ipopt's internal
3701    /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3702    /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3703    /// reads for reduced costs.
3704    final_z_l: Option<Vec<Number>>,
3705    final_z_u: Option<Vec<Number>>,
3706    /// Per-row Jacobian accumulator (length n).
3707    scratch_row_grad: Vec<f64>,
3708    /// Scratch buffers for `Tape::hessian_directional` (each sized
3709    /// to `max_tape_n`).
3710    vals_scratch: Vec<f64>,
3711    dot_scratch: Vec<f64>,
3712    adj_scratch: Vec<f64>,
3713    adj_dot_scratch: Vec<f64>,
3714    /// Per-color compressed Hessian-vector results, sized to
3715    /// `prob.n`. Reused across `eval_h` calls but allocated once.
3716    compressed: Vec<Vec<f64>>,
3717    /// Per-direction "carries signal" mask for `hessian_vector_products`,
3718    /// kept here rather than allocated per call. This crate holds an
3719    /// explicit no-per-call-allocation line on the tape sweeps (see
3720    /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3721    /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3722    /// Krylov iteration.
3723    hvp_live: Vec<bool>,
3724    /// Model-derived scaling factors (gh #703), computed on demand by
3725    /// [`NlTnlp::enable_curvature_scaling`] and served through
3726    /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3727    /// the state every solve that does not select `curvature-based` stays
3728    /// in — computing them costs a pass over every stored Hessian entry
3729    /// and nothing else reads them.
3730    curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3731}
3732
3733// ---------------------------------------------------------------------
3734// Human-readable equation rendering (`print equation` in the debugger).
3735//
3736// Turns a parsed constraint back into infix text using the model's
3737// variable / constraint names, so the debugger can show the actual
3738// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3739// bare row index. This is the "print the specific equation, with
3740// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3741// argue makes equation-oriented model diagnostics actionable.
3742//
3743// The renderer is intentionally separate from the evaluation `Tape`:
3744// tapes are lossy for display (CSEs flattened, externals opaque),
3745// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3746// ---------------------------------------------------------------------
3747
3748/// Binding strength for parenthesization. Higher binds tighter.
3749const P_ADD: u8 = 10;
3750const P_MUL: u8 = 20;
3751const P_NEG: u8 = 30;
3752const P_POW: u8 = 40;
3753const P_ATOM: u8 = 100;
3754
3755/// Format a numeric literal compactly: integers without a trailing `.0`,
3756/// everything else via the shortest round-tripping `f64` form.
3757fn fmt_num(x: Number) -> String {
3758    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3759        format!("{}", x as i64)
3760    } else {
3761        format!("{x}")
3762    }
3763}
3764
3765/// Display label for variable `i`: its `.col` name when present, else
3766/// `x[i]`.
3767fn var_label(i: usize, var_names: &[String]) -> String {
3768    match var_names.get(i) {
3769        Some(s) if !s.is_empty() => s.clone(),
3770        _ => format!("x[{i}]"),
3771    }
3772}
3773
3774/// Precedence of an expression's top operator (for child wrapping).
3775fn expr_prec(e: &Expr) -> u8 {
3776    match e {
3777        Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3778        Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3779        Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3780        Expr::Binary(BinOp::Pow, ..) => P_POW,
3781        Expr::Cse(inner) => expr_prec(inner),
3782        // Everything else renders as an atom / `f(...)` form.
3783        _ => P_ATOM,
3784    }
3785}
3786
3787/// Render an expression as infix text, using `var_names` for variable
3788/// labels where available (`x[i]` otherwise).
3789///
3790/// The debugger reaches the renderer through the constraint/objective
3791/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3792/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3793/// able to *see* the expression you just built is most of the debugging
3794/// story.
3795///
3796/// `Cse` bodies are inlined at every occurrence, so the output of a
3797/// heavily-shared DAG can be far larger than the DAG itself. Callers
3798/// rendering user-built expressions should bound the input first.
3799pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3800    render_expr(e, var_names, &[])
3801}
3802
3803/// Render `e`, wrapping in parentheses iff its precedence is looser than
3804/// `min_prec`.
3805fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
3806    let s = render_expr(e, vn, funcs);
3807    if expr_prec(e) < min_prec {
3808        format!("({s})")
3809    } else {
3810        s
3811    }
3812}
3813
3814fn unary_name(op: UnaryOp) -> &'static str {
3815    match op {
3816        UnaryOp::Neg => "-",
3817        UnaryOp::Sqrt => "sqrt",
3818        UnaryOp::Log => "log",
3819        UnaryOp::Exp => "exp",
3820        UnaryOp::Abs => "abs",
3821        UnaryOp::Sin => "sin",
3822        UnaryOp::Cos => "cos",
3823        UnaryOp::Log10 => "log10",
3824        UnaryOp::Tan => "tan",
3825        UnaryOp::Atan => "atan",
3826        UnaryOp::Acos => "acos",
3827        UnaryOp::Sinh => "sinh",
3828        UnaryOp::Cosh => "cosh",
3829        UnaryOp::Tanh => "tanh",
3830        UnaryOp::Asin => "asin",
3831        UnaryOp::Acosh => "acosh",
3832        UnaryOp::Asinh => "asinh",
3833        UnaryOp::Atanh => "atanh",
3834        UnaryOp::Erf => "erf",
3835        // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
3836        // the rendered text is read by humans debugging a model and must not
3837        // imply a sign the op does not have.
3838        UnaryOp::XLogX => "xlogx",
3839    }
3840}
3841
3842fn cmp_sym(op: CmpOp) -> &'static str {
3843    match op {
3844        CmpOp::Lt => "<",
3845        CmpOp::Le => "<=",
3846        CmpOp::Eq => "==",
3847        CmpOp::Ge => ">=",
3848        CmpOp::Gt => ">",
3849        CmpOp::Ne => "!=",
3850    }
3851}
3852
3853/// Append an additive sub-term with a tidy sign: a rendered term that
3854/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
3855/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
3856/// the term is itself a sum. The first term is emitted verbatim.
3857fn push_additive(out: &mut String, rendered: &str, first: bool) {
3858    if first {
3859        out.push_str(rendered);
3860    } else if let Some(rest) = rendered.strip_prefix('-') {
3861        out.push_str(" - ");
3862        out.push_str(rest);
3863    } else {
3864        out.push_str(" + ");
3865        out.push_str(rendered);
3866    }
3867}
3868
3869/// Render an [`Expr`] DAG to infix text using model names.
3870fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
3871    match e {
3872        Expr::Const(c) => fmt_num(*c),
3873        Expr::Var(i) => var_label(*i, vn),
3874        Expr::Binary(op, l, r) => match op {
3875            BinOp::Add => {
3876                let mut s = render_prec(l, P_ADD, vn, funcs);
3877                push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
3878                s
3879            }
3880            // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
3881            BinOp::Sub => format!(
3882                "{} - {}",
3883                render_prec(l, P_ADD, vn, funcs),
3884                render_prec(r, P_ADD + 1, vn, funcs)
3885            ),
3886            BinOp::Mul => format!(
3887                "{}*{}",
3888                render_prec(l, P_MUL, vn, funcs),
3889                render_prec(r, P_MUL, vn, funcs)
3890            ),
3891            BinOp::Div => format!(
3892                "{}/{}",
3893                render_prec(l, P_MUL, vn, funcs),
3894                render_prec(r, P_MUL + 1, vn, funcs)
3895            ),
3896            // Pow is right-associative: tighten the left operand instead.
3897            BinOp::Pow => format!(
3898                "{}^{}",
3899                render_prec(l, P_POW + 1, vn, funcs),
3900                render_prec(r, P_POW, vn, funcs)
3901            ),
3902            BinOp::Atan2 => format!(
3903                "atan2({}, {})",
3904                render_expr(l, vn, funcs),
3905                render_expr(r, vn, funcs)
3906            ),
3907            BinOp::CEntropy => format!(
3908                "centropy({}, {})",
3909                render_expr(l, vn, funcs),
3910                render_expr(r, vn, funcs)
3911            ),
3912        },
3913        Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
3914        Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
3915        Expr::Sum(xs) => {
3916            if xs.is_empty() {
3917                "0".to_string()
3918            } else {
3919                let mut s = String::new();
3920                for (k, x) in xs.iter().enumerate() {
3921                    push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
3922                }
3923                s
3924            }
3925        }
3926        Expr::Cse(inner) => render_expr(inner, vn, funcs),
3927        Expr::Funcall { id, args } => {
3928            let name = funcs
3929                .iter()
3930                .find(|f| f.id == *id)
3931                .map(|f| f.name.clone())
3932                .unwrap_or_else(|| format!("extern#{id}"));
3933            let parts: Vec<String> = args
3934                .iter()
3935                .map(|a| match a {
3936                    FuncallArg::Real(x) => render_expr(x, vn, funcs),
3937                    FuncallArg::Str(s) => format!("{s:?}"),
3938                })
3939                .collect();
3940            format!("{name}({})", parts.join(", "))
3941        }
3942        Expr::Compare(op, a, b) => format!(
3943            "({} {} {})",
3944            render_expr(a, vn, funcs),
3945            cmp_sym(*op),
3946            render_expr(b, vn, funcs)
3947        ),
3948        Expr::And(a, b) => format!(
3949            "({} && {})",
3950            render_expr(a, vn, funcs),
3951            render_expr(b, vn, funcs)
3952        ),
3953        Expr::Or(a, b) => format!(
3954            "({} || {})",
3955            render_expr(a, vn, funcs),
3956            render_expr(b, vn, funcs)
3957        ),
3958        Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
3959        Expr::Cond { cond, then_, else_ } => format!(
3960            "if({}, {}, {})",
3961            render_expr(cond, vn, funcs),
3962            render_expr(then_, vn, funcs),
3963            render_expr(else_, vn, funcs)
3964        ),
3965        Expr::MinList(xs) => format!(
3966            "min({})",
3967            xs.iter()
3968                .map(|x| render_expr(x, vn, funcs))
3969                .collect::<Vec<_>>()
3970                .join(", ")
3971        ),
3972        Expr::MaxList(xs) => format!(
3973            "max({})",
3974            xs.iter()
3975                .map(|x| render_expr(x, vn, funcs))
3976                .collect::<Vec<_>>()
3977                .join(", ")
3978        ),
3979    }
3980}
3981
3982/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
3983/// `a + -2*b`). Returns `""` when there are no linear terms.
3984fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
3985    let mut out = String::new();
3986    // The `.nl` linear part carries an entry for every variable in the
3987    // row's Jacobian, including a 0 coefficient for variables that appear
3988    // only *nonlinearly* (they're rendered in the nonlinear part). Skip
3989    // those zeros so the equation reads as written, not as a sparsity map.
3990    let mut first = true;
3991    for (var, coef) in linear {
3992        if *coef == 0.0 {
3993            continue;
3994        }
3995        let neg = *coef < 0.0;
3996        let mag = coef.abs();
3997        let term = if mag == 1.0 {
3998            var_label(*var, vn)
3999        } else {
4000            format!("{}*{}", fmt_num(mag), var_label(*var, vn))
4001        };
4002        if first {
4003            if neg {
4004                out.push('-');
4005            }
4006            out.push_str(&term);
4007            first = false;
4008        } else {
4009            out.push_str(if neg { " - " } else { " + " });
4010            out.push_str(&term);
4011        }
4012    }
4013    out
4014}
4015
4016/// Render the constraint body (linear + nonlinear parts combined).
4017fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
4018    let mut s = render_linear(linear, &prob.var_names);
4019    let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
4020    if !nl_is_zero {
4021        let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
4022        if s.is_empty() {
4023            s = nl;
4024        } else {
4025            push_additive(&mut s, &nl, false);
4026        }
4027    }
4028    if s.is_empty() {
4029        s = "0".to_string();
4030    }
4031    s
4032}
4033
4034/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
4035/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
4036/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
4037pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
4038    // Diagnostic path: a recognized body has no tree, so this is one of
4039    // the places that pays to rebuild one. It runs once per rendered row.
4040    let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
4041    let lo = prob.g_l[k];
4042    let hi = prob.g_u[k];
4043    const INF: Number = 1.0e19;
4044    let has_lo = lo > -INF;
4045    let has_hi = hi < INF;
4046    match (has_lo, has_hi) {
4047        (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
4048        (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
4049        (true, false) => format!("{body} >= {}", fmt_num(lo)),
4050        (false, true) => format!("{body} <= {}", fmt_num(hi)),
4051        (false, false) => format!("{body}  (free)"),
4052    }
4053}
4054
4055/// Render every constraint to text, index-aligned to `g` (original `.nl`
4056/// row order). Used to build the debugger's static equation book.
4057pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
4058    (0..prob.m)
4059        .map(|k| render_constraint_equation(prob, k))
4060        .collect()
4061}
4062
4063/// Structural sparsity of the constraint Jacobian as flat 0-based
4064/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
4065/// structurally depends on — the union of its linear support and the
4066/// `Var(i)` indices appearing anywhere in its nonlinear tree
4067/// ([`collect_vars`]). Sorted and deduplicated within each row.
4068///
4069/// This is the input to the debugger's Dulmage–Mendelsohn
4070/// structural-rank check (`diagnose`), which names the over-determined
4071/// (candidate redundant / inconsistent) equations and under-determined
4072/// variables. Naming the dependent rows — rather than reporting
4073/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
4074/// equation-oriented model debugging. See
4075/// <https://doi.org/10.69997/sct.147875>.
4076pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
4077    let mut irow: Vec<Index> = Vec::new();
4078    let mut jcol: Vec<Index> = Vec::new();
4079    let mut support: BTreeSet<usize> = BTreeSet::new();
4080    for k in 0..prob.m {
4081        support.clear();
4082        for &(j, _coef) in &prob.con_linear[k] {
4083            support.insert(j);
4084        }
4085        prob.con_nonlinear[k].collect_vars(&mut support);
4086        for &j in &support {
4087            irow.push(k as Index);
4088            jcol.push(j as Index);
4089        }
4090    }
4091    (irow, jcol)
4092}
4093
4094/// Flatten an additive expression tree into independent summand
4095/// expressions, each of which becomes its own Hessian tape.
4096///
4097/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
4098/// each summand tape once *per color the summand touches*, so the
4099/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
4100/// small (few variables → few colors) is what makes a sparse Hessian
4101/// cheap. A single fused tape spanning all `n` variables, by
4102/// contrast, is walked once per color → `O(n · tape_len)`, which on a
4103/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
4104/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
4105///
4106/// We therefore descend through the *affine* envelope of the sum, not
4107/// just `+`/`Sum`:
4108///
4109///   * `Neg(x)`            → split `x`, negate each summand
4110///   * `Sub(l, r)`         → split `l`; split `r`, negate each summand
4111///   * `c * x` / `x * c`   → split `x`, scale each summand by `c`
4112///   * `x / c`             → split `x`, scale each summand by `1/c`
4113///
4114/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
4115/// least-squares / max-entropy shapes) still decomposes to its leaf
4116/// terms instead of collapsing into one giant tape. The carried
4117/// `factor` is materialised onto each leaf only when it differs from
4118/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
4119/// is unchanged and the per-summand op count grows by at most one.
4120fn split_top_sums(expr: &Expr) -> Vec<Expr> {
4121    let mut out = Vec::new();
4122    fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4123        if factor == 1.0 {
4124            out.push(e.clone());
4125        } else if factor == -1.0 {
4126            out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
4127        } else {
4128            out.push(Expr::Binary(
4129                BinOp::Mul,
4130                Box::new(Expr::Const(factor)),
4131                Box::new(e.clone()),
4132            ));
4133        }
4134    }
4135    fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4136        match e {
4137            Expr::Sum(terms) => {
4138                for t in terms {
4139                    go(t, factor, out);
4140                }
4141            }
4142            Expr::Binary(BinOp::Add, l, r) => {
4143                go(l, factor, out);
4144                go(r, factor, out);
4145            }
4146            Expr::Binary(BinOp::Sub, l, r) => {
4147                go(l, factor, out);
4148                go(r, -factor, out);
4149            }
4150            Expr::Unary(UnaryOp::Neg, x) => {
4151                go(x, -factor, out);
4152            }
4153            // Affine scaling: distribute a constant coefficient into
4154            // the summands so a leading `c·(Σ …)` still splits.
4155            Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
4156                (Expr::Const(c), _) => go(r, factor * c, out),
4157                (_, Expr::Const(c)) => go(l, factor * c, out),
4158                _ => push_leaf(e, factor, out),
4159            },
4160            Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
4161                Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
4162                _ => push_leaf(e, factor, out),
4163            },
4164            _ => push_leaf(e, factor, out),
4165        }
4166    }
4167    go(expr, 1.0, &mut out);
4168    if out.is_empty() {
4169        out.push(Expr::Const(0.0));
4170    }
4171    out
4172}
4173
4174/// Greedy column coloring of a symmetric sparsity pattern stored
4175/// as lower-triangle pairs.
4176///
4177/// Builds the column-intersection graph: columns `c1` and `c2` are
4178/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
4179/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
4180/// satisfies the direct-recovery condition for symmetric Hessians
4181/// (Coleman-Moré): for any color, the columns it contains have
4182/// pairwise disjoint row supports, so a single H·s product
4183/// recovers them all unambiguously.
4184///
4185/// Returns `(var_color, n_colors)` where `var_color[k]` is the
4186/// color assigned to variable `k`, or `u32::MAX` for variables
4187/// not in any Hessian pair (they contribute nothing and don't
4188/// need a color).
4189/// A column is treated as **dense** — and peeled out of the coloring —
4190/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
4191/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
4192/// factor keeps uniformly-dense Hessians (where every column looks like
4193/// every other) on the plain coloring path, and the absolute floor stops
4194/// a very sparse average from declaring a 10-entry column "dense".
4195const DENSE_COL_FACTOR: usize = 16;
4196const DENSE_COL_MIN: usize = 32;
4197/// Largest relative error a peeled column may inflict on the smallest
4198/// entry recovered from its pass before
4199/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
4200///
4201/// The ratio is a worst-case bound — the pass's roundoff floor over the
4202/// smallest entry read out of it — and it is a *loose* one, by an amount
4203/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
4204/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
4205/// measures 4e-16. The cut is therefore calibrated on the corpus, not
4206/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
4207/// models that peel anything, the highest bound on a column that recovers
4208/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
4209/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
4210/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
4211///
4212/// 1e-8 sits below both, which deliberately buys correctness with speed:
4213/// the errors are asymmetric, since a false veto costs one model a coloring
4214/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
4215/// while a missed veto costs a solve its certificate. Five of the 56 take a
4216/// veto they do not need; none takes a wrong answer. Raising this constant
4217/// to buy those five back would put the cut inside a 2.8x window measured
4218/// on two model families, which is not a margin worth trading a certificate
4219/// for.
4220const PEEL_MAX_REL_ERR: f64 = 1e-8;
4221/// Hard cap on how many columns get peeled, applied on top of the
4222/// pay-for-itself rule in [`select_peeled_cols`].
4223const MAX_PEELED_COLS: usize = 256;
4224
4225/// Lower bound on the color count that results from peeling `peeled`.
4226///
4227/// Peeling costs one color per peeled column. On what remains, any row
4228/// with `d` surviving entries makes those `d` columns pairwise
4229/// conflicting, so the greedy walk needs at least `d` colors. Hence
4230/// `|peeled| + max surviving row degree` is a lower bound on the total,
4231/// computable in one O(nnz) pass — no coloring required.
4232///
4233/// The bound is what makes the choice decidable at all: the plain
4234/// coloring cannot be run as a comparison baseline, because on the
4235/// one-dense-row case the plain walk is itself O(n^2) — precisely the
4236/// blowup peeling exists to avoid.
4237fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
4238    let mut deg = vec![0usize; n];
4239    for &(i, j) in lower_pairs {
4240        if peeled[i] || peeled[j] {
4241            continue;
4242        }
4243        deg[j] += 1;
4244        if i != j {
4245            deg[i] += 1;
4246        }
4247    }
4248    let n_peeled = peeled.iter().filter(|&&p| p).count();
4249    n_peeled + deg.iter().copied().max().unwrap_or(0)
4250}
4251
4252/// Choose which of the candidate dense columns to actually peel.
4253///
4254/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
4255/// top `k` candidates by degree, over a doubling ladder of `k` up to
4256/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
4257/// so peeling has to earn its colors.
4258///
4259/// **Why not just truncate an over-long candidate list.** Cutting the
4260/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
4261/// columns that miss the cut stay in the conflict structure, so the base
4262/// color count is untouched and the singleton colors are pure addition.
4263/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
4264/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
4265/// regression in exactly the quantity peeling exists to reduce. The
4266/// bound above sees it: peeling `k` of several thousand equal-degree
4267/// columns leaves the surviving max degree unchanged, so every `k > 0`
4268/// scores strictly worse than peeling nothing.
4269///
4270/// **Why not a simple degree rule.** "Peel only columns denser than some
4271/// fraction of `n`" would refuse three rows of degree 10,000 in a
4272/// 200,000-variable model, where peeling three columns takes the
4273/// coloring from >= 10,000 down to a handful. The win depends on what
4274/// peeling leaves behind, not on the peeled column's degree alone.
4275fn select_peeled_cols(
4276    n: usize,
4277    lower_pairs: &[(usize, usize)],
4278    deg: &[usize],
4279    mut candidates: Vec<usize>,
4280) -> Vec<usize> {
4281    if candidates.is_empty() {
4282        return candidates;
4283    }
4284    // Worst offenders first; they remove the most conflict per color spent.
4285    candidates.sort_unstable_by(|&a, &b| deg[b].cmp(&deg[a]).then(a.cmp(&b)));
4286    candidates.truncate(MAX_PEELED_COLS);
4287
4288    let mut mask = vec![false; n];
4289    let mut best_k = 0usize;
4290    // Peeling nothing: the bound is just the largest row degree.
4291    let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
4292
4293    // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
4294    // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
4295    // mask only ever gains entries, so each step just marks the new slice.
4296    let mut marked = 0usize;
4297    let mut k = 1usize;
4298    loop {
4299        let k_now = k.min(candidates.len());
4300        for &j in &candidates[marked..k_now] {
4301            mask[j] = true;
4302        }
4303        marked = k_now;
4304        let bound = peel_color_bound(n, lower_pairs, &mask);
4305        if bound < best_bound {
4306            best_bound = bound;
4307            best_k = k_now;
4308        }
4309        if k_now == candidates.len() {
4310            break;
4311        }
4312        k *= 2;
4313    }
4314
4315    candidates.truncate(best_k);
4316    candidates
4317}
4318
4319/// Greedy distance-1 coloring of the Hessian's column-intersection
4320/// graph, with **dense columns peeled out**.
4321///
4322/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
4323/// marks a column that needs no directional product of its own: either
4324/// it has no Hessian entries at all, or every entry it has is recovered
4325/// from a peeled column's pass (see below).
4326///
4327/// # Why peeling
4328///
4329/// The plain coloring rule is "two columns may share a color when they
4330/// have no common nonzero row". A single **dense row** — one variable
4331/// multiplying a sum over all the others, a total-cost variable, a
4332/// shared design parameter — puts a nonzero in *every* column at that
4333/// row, so every pair of columns conflicts and the greedy walk hands out
4334/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
4335/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
4336/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
4337/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
4338///
4339/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
4340/// own singleton color: one directional product with seed `e_d` recovers
4341/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
4342/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
4343/// at row `j` of that same pass. So row `d` no longer constrains any
4344/// other column's color and is dropped from the conflict structure, and
4345/// the remaining columns colour on their genuine sparsity. On the
4346/// one-dense-row case above this takes `n_colors` from `n` to a handful.
4347///
4348/// # What peeling costs, and `peel_veto`
4349///
4350/// Recovering `H[d, j]` from column `d`'s pass is exact in real
4351/// arithmetic but not in floating point: the pass is accumulated at the
4352/// scale of the whole dense column, so every entry read out of it carries
4353/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
4354/// ordinary path — column `j`'s own pass — would have left a floor of
4355/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
4356/// dense column is well scaled, and they do on every peel-firing model in
4357/// the benchmark corpus but one. Where a peeled column spans a wide
4358/// dynamic range, though, that floor swamps its small entries: a column
4359/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4360///
4361/// Structure cannot see this — it is a property of the values — so
4362/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4363/// and passes the offenders back here in `peel_veto`, which bars them
4364/// from being peeled again. A vetoed column is colored normally, its row
4365/// returns to the conflict structure, and its entries go back to the
4366/// accurate path.
4367fn greedy_hessian_coloring(
4368    n: usize,
4369    lower_pairs: &[(usize, usize)],
4370    peel_veto: &[bool],
4371) -> (Vec<u32>, usize, Vec<bool>) {
4372    if n == 0 {
4373        return (Vec::new(), 0, Vec::new());
4374    }
4375
4376    // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4377    // i >= j contributes row i to column j and row j to column i; a
4378    // diagonal contributes once.
4379    let mut deg = vec![0usize; n];
4380    for &(i, j) in lower_pairs {
4381        deg[j] += 1;
4382        if i != j {
4383            deg[i] += 1;
4384        }
4385    }
4386
4387    // Pick the dense columns to peel.
4388    let total: usize = deg.iter().sum();
4389    let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4390    let mut peeled = vec![false; n];
4391    let candidates: Vec<usize> = (0..n)
4392        .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4393        .collect();
4394    let dense = select_peeled_cols(n, lower_pairs, &deg, candidates);
4395    for &j in &dense {
4396        peeled[j] = true;
4397    }
4398
4399    // Conflict structure over the *non-peeled* columns only. Pairs with
4400    // a peeled endpoint are recovered from that endpoint's own pass, so
4401    // they neither need a color nor constrain one.
4402    let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4403    let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4404    for &(i, j) in lower_pairs {
4405        if peeled[i] || peeled[j] {
4406            continue;
4407        }
4408        col_rows[j].push(i as u32);
4409        row_cols[i].push(j as u32);
4410        if i != j {
4411            col_rows[i].push(j as u32);
4412            row_cols[j].push(i as u32);
4413        }
4414    }
4415
4416    let mut var_color = vec![u32::MAX; n];
4417    let mut forbidden = vec![u32::MAX; n + 1];
4418    let mut n_colors: u32 = 0;
4419
4420    for j in 0..n {
4421        // Peeled columns are colored below; a column with no surviving
4422        // Hessian entries needs no color at all.
4423        if peeled[j] || col_rows[j].is_empty() {
4424            continue;
4425        }
4426        // Mark colors used by any column sharing a row with `j`.
4427        // Row-of-col -> col-in-row visit pattern collects all
4428        // distance-1 neighbors in the column-intersection graph.
4429        for &r in &col_rows[j] {
4430            for &c in &row_cols[r as usize] {
4431                if c as usize == j {
4432                    continue;
4433                }
4434                let cc = var_color[c as usize];
4435                if cc != u32::MAX {
4436                    forbidden[cc as usize] = j as u32;
4437                }
4438            }
4439        }
4440        // First color not stamped with `j as u32`.
4441        let mut chosen: u32 = 0;
4442        while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4443            chosen += 1;
4444        }
4445        var_color[j] = chosen;
4446        if chosen + 1 > n_colors {
4447            n_colors = chosen + 1;
4448        }
4449    }
4450
4451    // One singleton color per peeled column, appended after the shared
4452    // ones so the non-peeled numbering is untouched.
4453    for &j in &dense {
4454        var_color[j] = n_colors;
4455        n_colors += 1;
4456    }
4457
4458    (var_color, n_colors as usize, peeled)
4459}
4460
4461/// Everything downstream of the Hessian coloring: seed vectors, the
4462/// per-color decode table, the per-tape color sets, and the shared-CSE
4463/// per-color summand / prelude-reach tables.
4464///
4465/// Split out of [`NlTnlp::new`] because
4466/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4467/// time, with a peel veto in hand, once it has seen real Hessian values.
4468fn build_color_tables(
4469    n: usize,
4470    m: usize,
4471    lower_pairs: &[(usize, usize)],
4472    tape_mask: &[bool],
4473    peel_veto: &[bool],
4474    obj_tapes: &[Tape],
4475    con_tapes: &[Vec<Tape>],
4476    con_hybrid: Option<&mut ConHybrid>,
4477) -> ColorTables {
4478    // Hessian column coloring. The chromatic number of the
4479    // column-intersection graph bounds how many directional
4480    // Hessian-vector products we need per `eval_h` call —
4481    // typically O(stencil) for PDE-mesh problems.
4482    //
4483    // Only the entries a *tape* can write take part. `tape_mask` is empty
4484    // when every entry is a tape entry (no quadratic structure), which is
4485    // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4486    // entries are excluded, since they are scattered from their stored
4487    // values and never read out of a directional product. A variable that
4488    // appears only in quadratic rows then has no color at all
4489    // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4490    // column with no surviving entries.
4491    let colored_pairs: Vec<(usize, usize)>;
4492    let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4493        lower_pairs
4494    } else {
4495        colored_pairs = lower_pairs
4496            .iter()
4497            .zip(tape_mask)
4498            .filter_map(|(p, &t)| t.then_some(*p))
4499            .collect();
4500        &colored_pairs
4501    };
4502    let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4503
4504    // Per-color seed vectors (dense for O(1) Var lookup in
4505    // `Tape::hessian_directional`).
4506    let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4507    for (k, &c) in var_color.iter().enumerate() {
4508        if c != u32::MAX {
4509            seeds[c as usize][k] = 1.0;
4510        }
4511    }
4512
4513    // Per-color decoding table. For each lower-tri pair (i, j)
4514    // with i >= j, the entry belongs to column j's color: after
4515    // computing compressed_{c_j} = (H · s_{c_j}), the value at
4516    // row i is exactly H[i, j] (coloring guarantees no other
4517    // column in c_j has a nonzero at row i).
4518    // Built straight from `lower_pairs`, which is sorted, so each
4519    // color's table is in ascending `hess_idx` order and the decode
4520    // scatter walks `values` forward instead of hopping (the old
4521    // build drained a `HashMap`, whose iteration order is arbitrary).
4522    let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4523    for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4524        // An entry no tape writes has no pass to be decoded out of; the
4525        // quadratic scatter already put its value there.
4526        if !tape_mask.is_empty() && !tape_mask[idx] {
4527            continue;
4528        }
4529        // Which directional product recovers H[i, j]? Column `j`'s,
4530        // read at row `i` — except when `i` is a peeled column and
4531        // `j` is not: then `j` may have no color of its own, and the
4532        // entry is already in column `i`'s pass at row `j`, since
4533        // H[i, j] == H[j, i].
4534        let (c, row) = if peeled[i] && !peeled[j] {
4535            (var_color[i], j)
4536        } else {
4537            (var_color[j], i)
4538        };
4539        debug_assert!(
4540            c != u32::MAX,
4541            "Hessian pair ({i}, {j}) at index {idx} has no color"
4542        );
4543        decoding[c as usize].push(ColorWrite {
4544            row: row as u32,
4545            hess_idx: idx as u32,
4546        });
4547    }
4548
4549    // Per-tape distinct color set: for each tape, the colors
4550    // its variables fall into. `eval_h` loops over only these
4551    // (tape, color) pairs instead of n_tapes × n_colors.
4552    let tape_colors = |t: &Tape| -> Vec<u32> {
4553        let mut s: Vec<u32> = t
4554            .variables()
4555            .into_iter()
4556            .map(|v| var_color[v])
4557            .filter(|&c| c != u32::MAX)
4558            .collect();
4559        s.sort_unstable();
4560        s.dedup();
4561        s
4562    };
4563    let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4564    let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4565        .iter()
4566        .map(|row| row.iter().map(tape_colors).collect())
4567        .collect();
4568
4569    // Shared-CSE Hessian tables (issue #557): the per-color summand
4570    // lists, row lookup, and packed forward-value arena `eval_h`'s
4571    // hybrid path walks. Built whenever the hybrid tape is — not just
4572    // above the gate — so flipping `use_for_hess` on (tests, the
4573    // force env var) needs no extra setup; the cost is one f64 per
4574    // local op plus small index tables.
4575    if let Some(h) = con_hybrid {
4576        let n_sum = h.tape.n_summands();
4577        let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4578        let mut acc = 0usize;
4579        for s in &h.tape.summands {
4580            local_off.push(acc);
4581            acc += s.ops.len();
4582        }
4583        local_off.push(acc);
4584        h.local_vals_all = vec![0.0; acc];
4585        h.local_off = local_off;
4586
4587        let mut summand_row = vec![0u32; n_sum];
4588        for i in 0..m {
4589            for si in h.row_start[i]..h.row_start[i + 1] {
4590                summand_row[si] = i as u32;
4591            }
4592        }
4593        h.summand_row = summand_row;
4594
4595        // A summand's variable set (`all_vars`) equals its flat tape's,
4596        // so this is `con_tape_colors` inverted to color-major order —
4597        // the loop `eval_h` actually runs.
4598        let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4599        for (si, s) in h.tape.summands.iter().enumerate() {
4600            let mut cs: Vec<u32> = s
4601                .all_vars
4602                .iter()
4603                .map(|&v| var_color[v])
4604                .filter(|&c| c != u32::MAX)
4605                .collect();
4606            cs.sort_unstable();
4607            cs.dedup();
4608            for c in cs {
4609                by_color[c as usize].push(si as u32);
4610            }
4611        }
4612        // Per-color prelude reach: the union of `prelude_reach` over the
4613        // color's summands, ascending. A union of operand-closed
4614        // ascending sets is itself operand-closed and ascending, which is
4615        // exactly what the two prelude sweeps require. Deduped with an
4616        // epoch-tagged buffer so the build costs
4617        // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4618        // it saves — rather than `n_colors × |prelude|`.
4619        let np = h.tape.n_prelude_ops();
4620        let mut seen: Vec<u32> = vec![0; np];
4621        let mut epoch: u32 = 0;
4622        let mut reach: Vec<u32> = Vec::new();
4623        let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4624        for list in &by_color {
4625            reach_off.push(reach.len());
4626            epoch += 1;
4627            let start = reach.len();
4628            for &si in list {
4629                for &p in &h.tape.summands[si as usize].prelude_reach {
4630                    if seen[p] != epoch {
4631                        seen[p] = epoch;
4632                        reach.push(p as u32);
4633                    }
4634                }
4635            }
4636            reach[start..].sort_unstable();
4637        }
4638        reach_off.push(reach.len());
4639        h.hess_color_reach = reach;
4640        h.hess_color_reach_off = reach_off;
4641
4642        h.hess_color_summands = by_color;
4643        h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4644        h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4645        h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4646        h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4647        h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4648    }
4649
4650    ColorTables {
4651        var_color,
4652        n_colors,
4653        peeled_cols: peeled
4654            .iter()
4655            .enumerate()
4656            .filter(|(_, p)| **p)
4657            .map(|(j, _)| j as u32)
4658            .collect(),
4659        seeds,
4660        decoding,
4661        hess_color_index: HessColorIndex::build(n_colors, &con_tape_colors),
4662        obj_tape_colors,
4663        con_tape_colors,
4664    }
4665}
4666
4667/// The color-dependent half of an [`NlTnlp`], as built by
4668/// [`build_color_tables`].
4669struct ColorTables {
4670    var_color: Vec<u32>,
4671    n_colors: usize,
4672    /// Columns given a singleton color and dropped from the conflict
4673    /// structure. Small by construction (`MAX_PEELED_COLS`).
4674    peeled_cols: Vec<u32>,
4675    seeds: Vec<Vec<f64>>,
4676    decoding: Vec<Vec<ColorWrite>>,
4677    obj_tape_colors: Vec<Vec<u32>>,
4678    con_tape_colors: Vec<Vec<Vec<u32>>>,
4679    /// `con_tape_colors` inverted to color-major, CSR-packed: the (row, tape)
4680    /// pairs whose tape touches each color, and the objective tapes likewise.
4681    ///
4682    /// The flat `eval_h` walks tapes and, inside each, the colors it touches,
4683    /// so several rows accumulate into one `compressed[c]` — fine serially,
4684    /// unshareable across threads. Walking color-major makes each color's
4685    /// accumulator private to its task, at the cost of forwarding a tape once
4686    /// per color it touches instead of once (measured at 2.9 colors per tape
4687    /// on an AC-SCOPF model, against a directional sweep that costs several
4688    /// forwards, so ~20% more serial work for 28-way parallelism).
4689    ///
4690    /// Within a color the pairs stay in ascending (row, tape) order, which is
4691    /// the order the tape-major loop contributes them in, so the two walks
4692    /// accumulate identical sums in identical order — bit-for-bit, not merely
4693    /// within tolerance. `parallel_matches_serial_bit_for_bit` pins that.
4694    hess_color_index: HessColorIndex,
4695}
4696
4697/// Color-major (row, tape) work lists for the flat Hessian path. See
4698/// [`ColorTables::hess_color_index`].
4699#[derive(Debug, Default, Clone)]
4700pub(crate) struct HessColorIndex {
4701    /// Constraint (row, tape) pairs per color, CSR. The objective's tapes are
4702    /// deliberately absent: `eval_h` accumulates them into `compressed`
4703    /// *before* the constraint block and keeps doing so serially, which is
4704    /// what makes the parallel walk's per-color ordering identical to the
4705    /// serial one — objective first, then rows ascending.
4706    con_off: Vec<u32>,
4707    con_row: Vec<u32>,
4708    con_tape: Vec<u32>,
4709}
4710
4711impl HessColorIndex {
4712    fn build(n_colors: usize, con_tape_colors: &[Vec<Vec<u32>>]) -> Self {
4713        // Rows ascending, then tapes ascending: the tape-major loop's order.
4714        let mut con: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_colors];
4715        for (k, row) in con_tape_colors.iter().enumerate() {
4716            for (ti, colors) in row.iter().enumerate() {
4717                for &c in colors {
4718                    con[c as usize].push((k as u32, ti as u32));
4719                }
4720            }
4721        }
4722        let mut out = HessColorIndex {
4723            con_off: Vec::with_capacity(n_colors + 1),
4724            ..Default::default()
4725        };
4726        out.con_off.push(0);
4727        for list in &con {
4728            for &(k, ti) in list {
4729                out.con_row.push(k);
4730                out.con_tape.push(ti);
4731            }
4732            out.con_off.push(out.con_row.len() as u32);
4733        }
4734        out
4735    }
4736
4737    /// The (row, tape) pairs of color `c`, as two parallel slices.
4738    fn con_pairs(&self, c: usize) -> (&[u32], &[u32]) {
4739        let (a, b) = (self.con_off[c] as usize, self.con_off[c + 1] as usize);
4740        (&self.con_row[a..b], &self.con_tape[a..b])
4741    }
4742}
4743
4744impl NlTnlp {
4745    /// Build the TNLP, panicking if AMPL external-function resolution fails.
4746    ///
4747    /// Kept for the many infallible call sites (CLI, tests) that operate on
4748    /// `.nl` models known to need no external libraries. Surfaces that can be
4749    /// handed an arbitrary user model — notably the Python `read_nl` binding —
4750    /// must call [`Self::try_new`] instead so a missing `$AMPLFUNC` library
4751    /// becomes a catchable error rather than an uncatchable panic across the
4752    /// pyo3 boundary.
4753    pub fn new(prob: NlProblem) -> Self {
4754        Self::try_new(prob)
4755            .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4756    }
4757
4758    /// Build the TNLP, returning an error (instead of panicking) when AMPL
4759    /// imported functions named by the model can't be resolved — e.g.
4760    /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4761    /// referenced function id isn't registered by any loaded library.
4762    pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4763        // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4764        // objective — the A/B reference for the constant-structure path,
4765        // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4766        // fast path's derivatives are checked against the ones they replace
4767        // on a real model, and how a suspected fast-path bug is bisected
4768        // against a reference that computes the same numbers a different way.
4769        Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4770    }
4771
4772    /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4773    /// explicitly on or off.
4774    ///
4775    /// The env var `try_new` reads is process-global, which is exactly wrong
4776    /// for the differential test that has to build the *same* model both ways
4777    /// and compare the derivatives — so the knob is a parameter here and the
4778    /// env var only chooses its default.
4779    pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4780        // Resolve any AMPL imported (external) functions. Walk every
4781        // nonlinear expression to collect the funcall ids actually
4782        // referenced; load the libraries named in $AMPLFUNC and bind
4783        // each id to its (library, registered-name) pair so the tape
4784        // builder can emit live `TapeOp::Funcall` ops.
4785        let mut referenced: BTreeSet<usize> = BTreeSet::new();
4786        // Only trees can carry a `Funcall`: the recognizer refuses one, so
4787        // a recognized body provably contains none.
4788        for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4789            if let Some(e) = body.tree() {
4790                super::nl_external::collect_funcall_ids(e, &mut referenced);
4791            }
4792        }
4793        let resolver = if referenced.is_empty() {
4794            super::nl_external::ExternalResolver::default()
4795        } else {
4796            super::nl_external::ExternalResolver::build_for_problem(
4797                &prob.imported_funcs,
4798                &referenced,
4799            )?
4800        };
4801
4802        // Recognize the degree-≤2 objective and rows *before* anything is
4803        // taped, because the win is not in evaluating the tape faster — it
4804        // is in never building it. On `qcqp500-3c` the ten quadratic rows
4805        // are 2.32 M monomials, one `Tape` each; recognizing them first
4806        // means those 2.32 M tapes, their color lists and their per-tape
4807        // sparsity sets are never allocated.
4808        //
4809        // A row that is trivially zero is left alone: it has no nonlinear
4810        // part to replace, and routing it through a (necessarily empty)
4811        // quadratic form would touch every model in the corpus to save
4812        // nothing.
4813        let mut quad = QuadraticStructure::new(prob.m);
4814        if use_quadratic {
4815            // `is_expanded_quadratic` is the accuracy gate, not an
4816            // optimization: it admits only forms whose read-out repeats the
4817            // additions the `.nl` writer already wrote. See its docs — and
4818            // note it is checked *before* recognition, so a factored form
4819            // costs one cheap structural walk rather than a full expansion.
4820            // Recognition is the parser's job now (gh #588, Q5) for the
4821            // bodies it could reach; `admitted_quad_form` reads its answer
4822            // back, and still walks a tree for the bodies that kept one —
4823            // a `from_expressions` model, a factored row rewound by the
4824            // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
4825            //
4826            // A form that gate refuses is offered the *factored* read-out
4827            // before it falls back to a tape (gh #673): the gate is a
4828            // verdict on the expansion, not on the body, and a sum of
4829            // squared residuals keeps its structure by keeping its squares.
4830            // `push_body_form` owns that order.
4831            if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
4832                quad.assign_objective(f);
4833            }
4834            for k in 0..prob.m {
4835                if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
4836                    quad.assign_row(k, f);
4837                }
4838            }
4839        }
4840
4841        // Flatten objective and each constraint into independent
4842        // summands. Each summand becomes its own `Tape` (CSE bodies
4843        // are deduplicated within a tape via Rc identity in
4844        // `Tape::build`; bodies shared across summands are
4845        // duplicated, which we accept as a simplicity tradeoff).
4846        let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
4847            Vec::new()
4848        } else {
4849            split_top_sums(&prob.obj_expr())
4850                .iter()
4851                .map(|e| Tape::build_with_externals(e, &resolver))
4852                .collect()
4853        };
4854
4855        let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
4856        let mut con_roots: Vec<Expr> = Vec::new();
4857        let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
4858        for k in 0..prob.m {
4859            row_start.push(con_roots.len());
4860            // A row with a quadratic form contributes no tape and no
4861            // hybrid-tape root, so `con_tapes[k]` is empty and its
4862            // `row_start` range is empty too.
4863            if quad.row_form(k).is_some() {
4864                con_tapes.push(Vec::new());
4865                continue;
4866            }
4867            let summands = split_top_sums(&prob.con_expr(k));
4868            con_tapes.push(
4869                summands
4870                    .iter()
4871                    .map(|e| Tape::build_with_externals(e, &resolver))
4872                    .collect(),
4873            );
4874            // Move (not clone) the split summands into the root list: their
4875            // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
4876            // sharing on `Arc` pointer identity, so the roots must be the
4877            // same allocations the parse produced.
4878            con_roots.extend(summands);
4879        }
4880        row_start.push(con_roots.len());
4881
4882        // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
4883        // building only when some CSE body is actually referenced from two
4884        // or more summands — otherwise the prelude comes out empty and the
4885        // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
4886        // gates the opcodes `build_multi` would panic on.
4887        // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
4888        // whole constraint block. Diagnostic only: it is how the
4889        // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
4890        // on a real model, and how a suspected hybrid-path bug is bisected
4891        // against a reference that computes the same derivatives a
4892        // different way.
4893        let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
4894            None
4895        } else if hybrid_supported(&con_roots) {
4896            let tape = HybridTape::build_multi(&con_roots);
4897            (tape.n_prelude_ops() > 0).then(|| {
4898                let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
4899                let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
4900                // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
4901                // regardless of the op ratio. Diagnostic only — it is how the
4902                // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
4903                // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
4904                // models that sit below the gate.
4905                let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
4906                ConHybrid {
4907                    prelude_vals: vec![0.0; tape.n_prelude_ops()],
4908                    local_vals: vec![0.0; tape.max_summand_ops()],
4909                    local_adj: vec![0.0; tape.max_summand_ops()],
4910                    prelude_adj: vec![0.0; tape.n_prelude_ops()],
4911                    use_for_jac: flat_ops as f64
4912                        >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
4913                    use_for_hess: force_hess
4914                        || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
4915                    local_vals_all: Vec::new(),
4916                    local_off: Vec::new(),
4917                    summand_row: Vec::new(),
4918                    hess_color_summands: Vec::new(),
4919                    hess_color_reach: Vec::new(),
4920                    hess_color_reach_off: Vec::new(),
4921                    prelude_dot: Vec::new(),
4922                    hess_prelude_adj: Vec::new(),
4923                    prelude_adj_dot: Vec::new(),
4924                    local_dot: Vec::new(),
4925                    local_adj_dot: Vec::new(),
4926                    row_start,
4927                    tape,
4928                }
4929            })
4930        } else {
4931            None
4932        };
4933        drop(con_roots);
4934
4935        // Hessian-of-Lagrangian sparsity: union of each tape's own
4936        // structural Hessian sparsity.
4937        // One flat `Vec`, sorted and deduped once, rather than a global
4938        // `BTreeSet` fed a single insert at a time across every summand
4939        // in the model: sort+dedup walks contiguous memory where the tree
4940        // chased a pointer and allocated a node per entry. The result is
4941        // exactly the ascending order the rest of this function wants, so
4942        // it doubles as `lower_pairs` instead of being copied into it.
4943        let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
4944        for t in &obj_tapes {
4945            tape_pairs.extend(t.hessian_sparsity());
4946        }
4947        for row in &con_tapes {
4948            for t in row {
4949                tape_pairs.extend(t.hessian_sparsity());
4950            }
4951        }
4952        tape_pairs.sort_unstable();
4953        tape_pairs.dedup();
4954
4955        // The assembled pattern is the tapes' union *plus* the quadratic
4956        // forms'. They are kept apart because the coloring only ever needs
4957        // the tape half — a quadratic block's entries are scattered
4958        // directly, not recovered from a directional product — and coloring
4959        // a dense quadratic block would put the color count back at `n` to
4960        // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
4961        let mut lower_pairs = tape_pairs.clone();
4962        if !quad.is_empty() {
4963            for f in quad
4964                .objective_form()
4965                .into_iter()
4966                .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
4967            {
4968                lower_pairs.extend(
4969                    quad.lower_triangle(f)
4970                        .map(|(r, c, _)| (r as usize, c as usize)),
4971                );
4972            }
4973            lower_pairs.sort_unstable();
4974            lower_pairs.dedup();
4975        }
4976
4977        // Which assembled entries a tape can write. Both sides are sorted
4978        // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
4979        // mask is left empty (meaning "all of them") when nothing was
4980        // recognized.
4981        let h_tape_mask: Vec<bool> = if quad.is_empty() {
4982            Vec::new()
4983        } else {
4984            let mut mask = vec![false; lower_pairs.len()];
4985            let mut t = 0usize;
4986            for (idx, pair) in lower_pairs.iter().enumerate() {
4987                if t < tape_pairs.len() && tape_pairs[t] == *pair {
4988                    mask[idx] = true;
4989                    t += 1;
4990                }
4991            }
4992            debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
4993            mask
4994        };
4995        drop(tape_pairs);
4996
4997        let mut h_irow = Vec::with_capacity(lower_pairs.len());
4998        let mut h_jcol = Vec::with_capacity(lower_pairs.len());
4999        for &(hi, lo) in &lower_pairs {
5000            h_irow.push(hi as i32);
5001            h_jcol.push(lo as i32);
5002        }
5003
5004        // Bind each form's lower-triangle entries to their index in the
5005        // assembled pattern. `lower_pairs` is sorted, so the lookup is a
5006        // binary search — done once here, never again on the hot path.
5007        if !quad.is_empty() {
5008            quad.bind_slots(|r, c| {
5009                lower_pairs
5010                    .binary_search(&(r as usize, c as usize))
5011                    .unwrap_or_else(|_| {
5012                        unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
5013                    })
5014            });
5015        }
5016
5017        // Hessian column coloring and everything keyed off it. The
5018        // chromatic number of the column-intersection graph bounds how
5019        // many directional Hessian-vector products we need per `eval_h`
5020        // call — typically O(stencil) for PDE-mesh problems.
5021        let ColorTables {
5022            var_color,
5023            n_colors,
5024            peeled_cols,
5025            seeds,
5026            decoding,
5027            obj_tape_colors,
5028            con_tape_colors,
5029            hess_color_index,
5030        } = build_color_tables(
5031            prob.n,
5032            prob.m,
5033            &lower_pairs,
5034            &h_tape_mask,
5035            &vec![false; prob.n],
5036            &obj_tapes,
5037            &con_tapes,
5038            con_hybrid.as_mut(),
5039        );
5040
5041        // Per-row Jacobian sparsity = union of tape vars plus
5042        // linear-segment vars.
5043        let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
5044        let mut jac_nnz = 0;
5045        for (i, row_tapes) in con_tapes.iter().enumerate() {
5046            let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
5047            for t in row_tapes {
5048                cols.extend(t.variables());
5049            }
5050            // A quadratic row has no tape, so its Jacobian support comes
5051            // from the form: `Hx + a` is nonzero exactly on the union of the
5052            // Hessian's rows and the folded linear part.
5053            if let Some(f) = quad.row_form(i) {
5054                cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
5055            }
5056            cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
5057            cols.sort_unstable();
5058            cols.dedup();
5059            cols.shrink_to_fit();
5060            jac_nnz += cols.len();
5061            jac_cols.push(cols);
5062        }
5063
5064        let mut max_tape_n: usize = 0;
5065        for t in &obj_tapes {
5066            max_tape_n = max_tape_n.max(t.ops.len());
5067        }
5068        for row in &con_tapes {
5069            for t in row {
5070                max_tape_n = max_tape_n.max(t.ops.len());
5071            }
5072        }
5073
5074        if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
5075            // Mean number of Hessian colors a single constraint tape touches.
5076            // It is the cost multiplier of visiting the work color-major
5077            // rather than tape-major: each (tape, color) pair needs the
5078            // tape's forward values, so a color-major walk forwards each tape
5079            // this many times instead of once.
5080            let (mut tape_color_pairs, mut n_live_tapes) = (0usize, 0usize);
5081            for row in &con_tape_colors {
5082                for cols in row {
5083                    if !cols.is_empty() {
5084                        tape_color_pairs += cols.len();
5085                        n_live_tapes += 1;
5086                    }
5087                }
5088            }
5089            let colors_per_tape = tape_color_pairs as f64 / n_live_tapes.max(1) as f64;
5090            let n_obj = obj_tapes.len();
5091            let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
5092            let total = n_obj + n_con;
5093            let mut sum_ops: usize = 0;
5094            for t in &obj_tapes {
5095                sum_ops += t.ops.len();
5096            }
5097            for row in &con_tapes {
5098                for t in row {
5099                    sum_ops += t.ops.len();
5100                }
5101            }
5102            let t = total.max(1);
5103            let nnz_h = h_irow.len();
5104            let avg_decode =
5105                decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
5106            eprintln!(
5107                "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
5108                 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
5109                 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h} \
5110                 colors_per_tape={colors_per_tape:.2}",
5111                sum_ops as f64 / t as f64,
5112            );
5113            // Flat vs shared-CSE op counts for the constraint block. The
5114            // ratio is how much duplicated CSE work `eval_g`'s hybrid path
5115            // avoids, and the ceiling on what routing the Jacobian /
5116            // Hessian through the same prelude could save.
5117            match &con_hybrid {
5118                Some(h) => {
5119                    let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5120                    let prelude = h.tape.n_prelude_ops();
5121                    let local = h.tape.total_local_ops();
5122                    eprintln!(
5123                        "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
5124                         local_ops={local} shared_total={} flat/shared={:.2}x \
5125                         jac_gate={} hess_gate={}",
5126                        prelude + local,
5127                        flat as f64 / (prelude + local).max(1) as f64,
5128                        if h.use_for_jac { "on" } else { "off" },
5129                        if h.use_for_hess { "on" } else { "off" },
5130                    );
5131                }
5132                None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
5133            }
5134            // What the constant-structure path took off the tape builder.
5135            // `forms` counts the objective too, which is why it can exceed
5136            // the row count by one.
5137            let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
5138            // How many of those forms the *parser* produced, i.e. how many
5139            // never cost an `Expr` (gh #588, Q5). The rest were recognized
5140            // from a tree that was built: a `from_expressions` model, or a
5141            // body the parser rewound because it was not degree 2.
5142            let parsed = std::iter::once(&prob.obj_nonlinear)
5143                .chain(prob.con_nonlinear.iter())
5144                .filter(|b| b.quad().is_some())
5145                .count();
5146            eprintln!(
5147                "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
5148                 stored_h_entries={} colored_pairs={}/{}",
5149                quad.len(),
5150                prob.m,
5151                if quad.objective_form().is_some() {
5152                    "quadratic"
5153                } else {
5154                    "taped"
5155                },
5156                quad.stored_entries(),
5157                if h_tape_mask.is_empty() {
5158                    lower_pairs.len()
5159                } else {
5160                    h_tape_mask.iter().filter(|&&t| t).count()
5161                },
5162                lower_pairs.len(),
5163            );
5164        }
5165
5166        let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
5167
5168        let mut me = Self {
5169            prob,
5170            obj_tapes,
5171            con_tapes,
5172            con_hybrid,
5173            quad,
5174            h_tape_mask,
5175            h_irow,
5176            h_jcol,
5177            jac_cols,
5178            jac_nnz,
5179            seeds,
5180            decoding,
5181            obj_tape_colors,
5182            con_tape_colors,
5183            hess_color_index,
5184            jac_row_off: Vec::new(),
5185            #[cfg(not(target_arch = "wasm32"))]
5186            jac_scratch_pool: Vec::new(),
5187            var_color,
5188            peeled_cols,
5189            final_x: None,
5190            final_obj: 0.0,
5191            final_lambda: None,
5192            final_z_l: None,
5193            final_z_u: None,
5194            scratch_row_grad: Vec::new(),
5195            vals_scratch: vec![0.0; max_tape_n],
5196            dot_scratch: vec![0.0; max_tape_n],
5197            adj_scratch: vec![0.0; max_tape_n],
5198            adj_dot_scratch: vec![0.0; max_tape_n],
5199            compressed,
5200            hvp_live: Vec::new(),
5201            curvature_scaling: None,
5202        };
5203        me.veto_ill_conditioned_peels();
5204        Ok(me)
5205    }
5206
5207    /// Un-peel any dense column whose own pass is too ill-scaled to read
5208    /// its small entries out of, and re-color if that changes anything.
5209    ///
5210    /// `greedy_hessian_coloring` picks the peel set from structure alone,
5211    /// which is the right call for the memory and the color count but
5212    /// blind to the one thing that can go wrong: an entry recovered from
5213    /// column `d`'s pass inherits that pass's roundoff floor, about
5214    /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
5215    /// well-scaled dense column loses nothing to that — the recovered
5216    /// entries come back bit-identical to the uncompressed reference on
5217    /// every peel-firing model in the benchmark corpus but one. A column
5218    /// spanning many orders of magnitude, though, hands its small entries
5219    /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
5220    /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
5221    /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
5222    /// survives that, but the multipliers come out of the KKT system the
5223    /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
5224    /// the solve stalls short of `Optimal` on a problem it used to
5225    /// certify.
5226    ///
5227    /// Nothing structural distinguishes the two cases, so measure it: one
5228    /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
5229    /// column's exact pass sitting in `compressed`, and a column whose
5230    /// worst recovered entry would lose more than
5231    /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
5232    /// one `eval_h` — at the peeled color count, so cheap — and only for
5233    /// the ~3% of models that peel anything at all.
5234    fn veto_ill_conditioned_peels(&mut self) {
5235        if self.peeled_cols.is_empty() {
5236            return;
5237        }
5238
5239        let mut values = vec![0.0; self.h_irow.len()];
5240        let lambda = vec![1.0; self.prob.m];
5241        let x0 = self.prob.x0.clone();
5242        if !self.eval_h(
5243            Some(&x0),
5244            true,
5245            1.0,
5246            Some(&lambda),
5247            true,
5248            SparsityRequest::Values {
5249                values: &mut values,
5250            },
5251        ) {
5252            return;
5253        }
5254
5255        let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
5256        // A column whose whole pass is negligible against the Hessian as a
5257        // whole cannot move the KKT system no matter how badly its own
5258        // entries are rounded, and columns that are identically zero at
5259        // `x0` would otherwise veto on a ratio of pure noise.
5260        let h_scale = self
5261            .compressed
5262            .iter()
5263            .flat_map(|c| c.iter())
5264            .fold(0.0f64, |a, &v| a.max(v.abs()));
5265        let mut peel_veto = vec![false; self.prob.n];
5266        let mut vetoed = 0usize;
5267        for &d in &self.peeled_cols {
5268            let c = self.var_color[d as usize];
5269            if c == u32::MAX {
5270                continue;
5271            }
5272            let pass = &self.compressed[c as usize];
5273            // The floor the pass was accumulated at, against the smallest
5274            // entry actually read out of it.
5275            let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
5276            // Entries at or below the pass's own roundoff floor carry no
5277            // information to lose: `eps * scale` is the noise the pass was
5278            // accumulated at, so such an entry is already indistinguishable
5279            // from zero whether or not the column is peeled. Including them
5280            // would divide by that noise -- `orthregd` holds entries of
5281            // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
5282            // 4e-16 against an uncompressed reference.
5283            let noise = f64::EPSILON * scale;
5284            let smallest = self.decoding[c as usize]
5285                .iter()
5286                .map(|w| pass[w.row as usize].abs())
5287                .filter(|v| *v > noise)
5288                .fold(f64::INFINITY, f64::min);
5289            if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
5290                continue;
5291            }
5292            if scale <= h_scale * f64::EPSILON {
5293                continue;
5294            }
5295            let rel_err = f64::EPSILON * scale / smallest;
5296            if dbg {
5297                eprintln!(
5298                    "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
5299                     min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
5300                    if rel_err > PEEL_MAX_REL_ERR {
5301                        " VETO"
5302                    } else {
5303                        ""
5304                    }
5305                );
5306            }
5307            if rel_err > PEEL_MAX_REL_ERR {
5308                peel_veto[d as usize] = true;
5309                vetoed += 1;
5310            }
5311        }
5312
5313        if vetoed == 0 {
5314            return;
5315        }
5316        if dbg {
5317            eprintln!(
5318                "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
5319                self.peeled_cols.len()
5320            );
5321        }
5322        self.recolor(&peel_veto);
5323    }
5324
5325    /// Rebuild the coloring and everything keyed off it, barring
5326    /// `peel_veto` from the peel set.
5327    fn recolor(&mut self, peel_veto: &[bool]) {
5328        let lower_pairs: Vec<(usize, usize)> = self
5329            .h_irow
5330            .iter()
5331            .zip(&self.h_jcol)
5332            .map(|(&i, &j)| (i as usize, j as usize))
5333            .collect();
5334        let ColorTables {
5335            var_color,
5336            n_colors,
5337            peeled_cols,
5338            seeds,
5339            decoding,
5340            obj_tape_colors,
5341            con_tape_colors,
5342            hess_color_index,
5343        } = build_color_tables(
5344            self.prob.n,
5345            self.prob.m,
5346            &lower_pairs,
5347            &self.h_tape_mask,
5348            peel_veto,
5349            &self.obj_tapes,
5350            &self.con_tapes,
5351            self.con_hybrid.as_mut(),
5352        );
5353        self.var_color = var_color;
5354        self.peeled_cols = peeled_cols;
5355        self.seeds = seeds;
5356        self.decoding = decoding;
5357        self.obj_tape_colors = obj_tape_colors;
5358        self.con_tape_colors = con_tape_colors;
5359        // Recoloring invalidates the color-major index: its entries name
5360        // colors, so a stale one would send a row's contribution to whatever
5361        // color now holds that slot. Refreshed here, not rebuilt lazily,
5362        // because nothing downstream can detect the staleness.
5363        self.hess_color_index = hess_color_index;
5364        self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
5365    }
5366
5367    pub fn final_x(&self) -> Option<&[Number]> {
5368        self.final_x.as_deref()
5369    }
5370
5371    pub fn final_obj(&self) -> Number {
5372        self.final_obj
5373    }
5374
5375    /// Converged constraint multipliers from the last solve, in original
5376    /// `.nl` row order. `None` before a solve finishes. See
5377    /// [`Self::final_x`] for the primal counterpart.
5378    pub fn final_lambda(&self) -> Option<&[Number]> {
5379        self.final_lambda.as_deref()
5380    }
5381
5382    /// Converged lower / upper bound multipliers from the last solve, in
5383    /// original `.nl` variable order and Ipopt's internal convention (both
5384    /// `>= 0`). `None` before a solve finishes.
5385    pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
5386        Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
5387    }
5388
5389    /// The parsed problem this TNLP evaluates (bounds, starting point,
5390    /// names, suffixes). Read-only; per-instance overrides go through
5391    /// [`Self::variant`].
5392    pub fn problem(&self) -> &NlProblem {
5393        &self.prob
5394    }
5395
5396    /// Opt this model in to **curvature-based** scaling (gh #703): compute
5397    /// the per-variable and per-row factors of
5398    /// [`crate::nl_scaling::curvature_scaling`] and serve them from
5399    /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
5400    /// them through the channel it already has for user factors.
5401    ///
5402    /// Returns `false` when the model is not one the scheme is defined for
5403    /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
5404    /// exists. The caller must surface that as an error rather than solving
5405    /// unscaled: an accepted scaling option that is then quietly not applied
5406    /// is exactly the gh #483 failure.
5407    ///
5408    /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
5409    /// passes over the magnitude surrogates, and nothing at all for a solve
5410    /// that never calls it.
5411    pub fn enable_curvature_scaling(&mut self) -> bool {
5412        match crate::nl_scaling::curvature_scaling(&self.prob) {
5413            Some(sc) => {
5414                self.curvature_scaling = Some(sc);
5415                true
5416            }
5417            None => false,
5418        }
5419    }
5420
5421    /// Whether [`Self::enable_curvature_scaling`] has been called and
5422    /// succeeded.
5423    pub fn curvature_scaling_enabled(&self) -> bool {
5424        self.curvature_scaling.is_some()
5425    }
5426
5427    /// Whether the enabled curvature scaling actually read any curvature —
5428    /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
5429    /// scaling is not enabled, and `false` for a degree-≤2 model whose every
5430    /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
5431    /// equilibration of `[A b]`.
5432    pub fn curvature_scaling_read_curvature(&self) -> bool {
5433        self.curvature_scaling
5434            .as_ref()
5435            .is_some_and(|sc| sc.quadratic)
5436    }
5437
5438    /// Is constraint row `i` evaluated from a constant quadratic form
5439    /// rather than from an AD tape (gh #588, Q4)?
5440    ///
5441    /// Structural, so it answers before any evaluation. Exposed for the
5442    /// differential test, which has to know which models exercise the fast
5443    /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
5444    pub fn quadratic_row(&self, i: usize) -> bool {
5445        self.quad.row_form(i).is_some()
5446    }
5447
5448    /// As [`Self::quadratic_row`], for the objective.
5449    pub fn quadratic_objective(&self) -> bool {
5450        self.quad.objective_form().is_some()
5451    }
5452
5453    /// Structural set of variables that appear in *some* nonlinear part —
5454    /// the union of `collect_vars` over the objective and every constraint
5455    /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5456    /// list below so the two can never disagree.
5457    fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5458        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5459        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5460        for row in &self.prob.con_nonlinear {
5461            row.collect_vars(&mut nonlinear);
5462        }
5463        nonlinear
5464    }
5465
5466    /// The nonlinear-variable list published through the TNLP contract
5467    /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5468    /// ascending, in the C index style [`NlTnlp`] reports.
5469    ///
5470    /// The contract's asymmetry decides how this is computed. A consumer
5471    /// treats every variable *absent* from the list as linear — Ipopt's
5472    /// limited-memory Hessian skips the quasi-Newton update in that
5473    /// subspace — so naming too few variables is a wrong answer, while
5474    /// naming too many merely costs work. Hence:
5475    ///
5476    /// * When the `.nl` header says **every** variable is nonlinear, publish
5477    ///   that and skip the walk. This is the maximally conservative answer,
5478    ///   so it is sound whatever the header's provenance, and it is the
5479    ///   common case for the models this matters on (`eigena2`: 110 of 110).
5480    /// * Otherwise walk the trees. The header's prefix
5481    ///   (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5482    ///   answer, but it would be one that *trusts* the writer to have
5483    ///   ordered the variables as the format requires, and the walk is
5484    ///   already paid once per solve by `get_variables_linearity`. The
5485    ///   walk is also the only option for a model built through
5486    ///   [`NlProblem::from_expressions`], which has no header at all.
5487    ///
5488    /// The two disagree only in the safe direction, which
5489    /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5490    /// fixture corpus: the walked set always sits inside the header's
5491    /// prefix, because `parse_nl_text` folds constant `C` bodies away
5492    /// (`gh #492`) and AMPL's own census predates that fold.
5493    fn nonlinear_variables(&self) -> Vec<Index> {
5494        if let Some(c) = self.prob.nl_counts
5495            && c.nonlinear_vars() >= self.prob.n
5496        {
5497            return (0..self.prob.n as Index).collect();
5498        }
5499        self.nonlinear_var_set()
5500            .into_iter()
5501            .map(|i| i as Index)
5502            .collect()
5503    }
5504
5505    /// Mutable access to that same problem, for a caller that owns this
5506    /// TNLP outright.
5507    ///
5508    /// The tapes were built from the expressions in [`Self::problem`] and
5509    /// are not rebuilt, so editing an expression here does **not** change
5510    /// what this TNLP evaluates. It exists for teardown: the Python
5511    /// binding takes the expression trees out through here so a deeply
5512    /// nested one is dropped on a stack chosen for it rather than
5513    /// recursively on whatever thread collected the object (pounce#472).
5514    pub fn problem_mut(&mut self) -> &mut NlProblem {
5515        &mut self.prob
5516    }
5517
5518    /// Hessian-vector product of the Lagrangian:
5519    /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5520    ///
5521    /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5522    /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5523    /// compressed columns into the sparse lower triangle; here the seed is
5524    /// the caller's `v` directly, so it is a single forward-over-reverse
5525    /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5526    /// chromatic number. That is what makes it usable on models where
5527    /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5528    /// truncated-CG step only ever needs `∇²L · v`.
5529    ///
5530    /// Sign convention matches `eval_h` and the rest of this evaluator: a
5531    /// `maximize` model's objective is negated so the returned operator is
5532    /// the one that minimizing solves. `lambda` is `None` for the objective
5533    /// block alone.
5534    ///
5535    /// `out` is overwritten (not accumulated into). Errors on any length
5536    /// mismatch rather than panicking, since the Python binding hands this
5537    /// arbitrary user arrays.
5538    pub fn hessian_vector_product(
5539        &mut self,
5540        x: &[Number],
5541        v: &[Number],
5542        obj_factor: Number,
5543        lambda: Option<&[Number]>,
5544        out: &mut [Number],
5545    ) -> Result<(), String> {
5546        self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5547    }
5548
5549    /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5550    /// once, `out[:, c] = ∇²L · v[:, c]`.
5551    ///
5552    /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5553    /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5554    ///
5555    /// Worth having as its own entry point rather than a loop over the
5556    /// single-vector call: the forward sweep depends only on `x`, so a block
5557    /// runs it *once per tape* and reuses `vals` across all `k` directions,
5558    /// where `k` separate calls would redo it `k` times. Only the
5559    /// forward-tangent + reverse-over-tangent passes are per-direction. That
5560    /// is the shape a block-Krylov solve, a directional-derivative probe, or
5561    /// a densify-the-Hessian loop wants.
5562    ///
5563    /// An all-zero direction is skipped, so passing a sparse block whose
5564    /// columns are mostly empty costs only the columns that carry signal.
5565    /// (The sparsity that dominates is the model's own: each tape touches
5566    /// only its own variables, and `hessian_directional` is O(tape ops), not
5567    /// O(n).)
5568    pub fn hessian_vector_products(
5569        &mut self,
5570        x: &[Number],
5571        v: &[Number],
5572        k: usize,
5573        obj_factor: Number,
5574        lambda: Option<&[Number]>,
5575        out: &mut [Number],
5576    ) -> Result<(), String> {
5577        let (n, m) = (self.prob.n, self.prob.m);
5578        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5579            if got == want {
5580                Ok(())
5581            } else {
5582                Err(format!(
5583                    "hessian_vector_product: {name} has length {got}, expected {want}"
5584                ))
5585            }
5586        };
5587        check("x", x.len(), n)?;
5588        check("v", v.len(), n * k)?;
5589        check("out", out.len(), n * k)?;
5590        if let Some(lam) = lambda {
5591            check("lambda", lam.len(), m)?;
5592        }
5593
5594        out.fill(0.0);
5595        if k == 0 || n == 0 {
5596            return Ok(());
5597        }
5598
5599        // Which directions carry signal. Computed once, not per (tape,
5600        // direction) pair — with many small summand tapes the scan would
5601        // otherwise dominate the work it is meant to save. Reuses the
5602        // persistent mask so a Krylov loop allocates nothing per iteration.
5603        self.hvp_live.clear();
5604        self.hvp_live
5605            .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5606        if !self.hvp_live.iter().any(|&l| l) {
5607            return Ok(());
5608        }
5609
5610        let obj_seed = if self.prob.minimize {
5611            obj_factor
5612        } else {
5613            -obj_factor
5614        };
5615        // The constant blocks. `H · v` for a quadratic form is a matvec
5616        // against stored values, so unlike a tape it needs no forward sweep
5617        // and no `x` at all. Easy to forget — a matrix-free solve that
5618        // silently dropped the quadratic part of `∇²L` would still converge,
5619        // just to a different point by a different route, which is the
5620        // failure mode this series is most exposed to.
5621        if !self.quad.is_empty() {
5622            if obj_seed != 0.0 {
5623                if let Some(f) = self.quad.objective_form() {
5624                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5625                        if self.hvp_live[c] {
5626                            self.quad.add_hessian_vector(
5627                                f,
5628                                &v[c * n..(c + 1) * n],
5629                                obj_seed,
5630                                out_col,
5631                            );
5632                        }
5633                    }
5634                }
5635            }
5636            if let Some(lam) = lambda {
5637                for (i, &w) in lam.iter().enumerate() {
5638                    if w == 0.0 {
5639                        continue;
5640                    }
5641                    let Some(f) = self.quad.row_form(i) else {
5642                        continue;
5643                    };
5644                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5645                        if self.hvp_live[c] {
5646                            self.quad
5647                                .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5648                        }
5649                    }
5650                }
5651            }
5652        }
5653
5654        if obj_seed != 0.0 {
5655            for t in &self.obj_tapes {
5656                if t.ops.is_empty() {
5657                    continue;
5658                }
5659                // Once per tape, not once per direction — the whole point
5660                // of the block form.
5661                t.forward_into(x, &mut self.vals_scratch);
5662                for (c, out_col) in out.chunks_mut(n).enumerate() {
5663                    if !self.hvp_live[c] {
5664                        continue;
5665                    }
5666                    t.hessian_directional(
5667                        &self.vals_scratch,
5668                        &v[c * n..(c + 1) * n],
5669                        obj_seed,
5670                        out_col,
5671                        &mut self.dot_scratch,
5672                        &mut self.adj_scratch,
5673                        &mut self.adj_dot_scratch,
5674                    );
5675                }
5676            }
5677        }
5678
5679        if let Some(lam) = lambda {
5680            // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5681            // range for every k.
5682            for (i, &w) in lam.iter().enumerate() {
5683                if w == 0.0 {
5684                    continue;
5685                }
5686                for t in &self.con_tapes[i] {
5687                    if t.ops.is_empty() {
5688                        continue;
5689                    }
5690                    t.forward_into(x, &mut self.vals_scratch);
5691                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5692                        if !self.hvp_live[c] {
5693                            continue;
5694                        }
5695                        t.hessian_directional(
5696                            &self.vals_scratch,
5697                            &v[c * n..(c + 1) * n],
5698                            w,
5699                            out_col,
5700                            &mut self.dot_scratch,
5701                            &mut self.adj_scratch,
5702                            &mut self.adj_dot_scratch,
5703                        );
5704                    }
5705                }
5706            }
5707        }
5708
5709        Ok(())
5710    }
5711
5712    /// Clone this TNLP with per-instance overrides applied — the
5713    /// "one structure, many bound / starting-point variations" case of
5714    /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5715    /// coloring are reused via `Clone` (they depend only on the model
5716    /// structure, which a variation cannot change); only the values in
5717    /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5718    /// are replaced. Any stale `final_x` from a previous solve of
5719    /// `self` is cleared on the clone.
5720    ///
5721    /// Errors when an override's length does not match the model
5722    /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5723    pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5724        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5725            if got == want {
5726                Ok(())
5727            } else {
5728                Err(format!(
5729                    "NlVariation.{name} has length {got}, expected {want}"
5730                ))
5731            }
5732        };
5733        let mut out = self.clone();
5734        out.final_x = None;
5735        out.final_obj = 0.0;
5736        out.final_lambda = None;
5737        out.final_z_l = None;
5738        out.final_z_u = None;
5739        if let Some(x0) = &v.x0 {
5740            check("x0", x0.len(), self.prob.n)?;
5741            out.prob.x0.clone_from(x0);
5742        }
5743        if let Some(x_l) = &v.x_l {
5744            check("x_l", x_l.len(), self.prob.n)?;
5745            out.prob.x_l.clone_from(x_l);
5746        }
5747        if let Some(x_u) = &v.x_u {
5748            check("x_u", x_u.len(), self.prob.n)?;
5749            out.prob.x_u.clone_from(x_u);
5750        }
5751        if let Some(g_l) = &v.g_l {
5752            check("g_l", g_l.len(), self.prob.m)?;
5753            out.prob.g_l.clone_from(g_l);
5754        }
5755        if let Some(g_u) = &v.g_u {
5756            check("g_u", g_u.len(), self.prob.m)?;
5757            out.prob.g_u.clone_from(g_u);
5758        }
5759        Ok(out)
5760    }
5761
5762    /// Build one [`NlTnlp`] per variation, sharing this instance's
5763    /// structure (see [`Self::variant`]). Returns instances in input
5764    /// order; errors on the first length-mismatched variation.
5765    pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5766        vs.iter().map(|v| self.variant(v)).collect()
5767    }
5768}
5769
5770/// Per-instance overrides for building a family of related NLP
5771/// instances from one parsed `.nl` model (pounce#126): same structure
5772/// and tapes, different starting point and/or bounds — parametric
5773/// sweeps, multi-start, or branch-and-bound node relaxations where
5774/// each node only tightens variable bounds. `None` keeps the base
5775/// model's value.
5776#[derive(Debug, Clone, Default)]
5777pub struct NlVariation {
5778    pub x0: Option<Vec<Number>>,
5779    pub x_l: Option<Vec<Number>>,
5780    pub x_u: Option<Vec<Number>>,
5781    pub g_l: Option<Vec<Number>>,
5782    pub g_u: Option<Vec<Number>>,
5783}
5784
5785impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5786    /// Per-`.nl`-row constraint expression tape, with the linear
5787    /// part folded in. Returns `None` for constraints that contribute
5788    /// neither a nonlinear expression nor any linear coefficients
5789    /// (so FBBT skips them — there's nothing to tighten).
5790    fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5791        if i >= self.prob.con_nonlinear.len() {
5792            return None;
5793        }
5794        let nonlinear = self.prob.con_expr(i);
5795        let linear = self
5796            .prob
5797            .con_linear
5798            .get(i)
5799            .map(|v| v.as_slice())
5800            .unwrap_or(&[]);
5801        // FBBT needs the tree, so a recognized body is rebuilt here. It is
5802        // rebuilt once per row per presolve pass and dropped again, so the
5803        // DAG never all exists at once — which is the property the phase is
5804        // actually about. Translating the stored coefficients instead would
5805        // propagate bounds through a different association and change the
5806        // tightening; that is not a trade this phase makes.
5807        crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
5808    }
5809
5810    /// Variable name from the sibling `.col` file, if one was loaded.
5811    /// Index is original `.nl` column order.
5812    fn variable_name(&self, i: usize) -> Option<&str> {
5813        self.prob.var_names.get(i).map(String::as_str)
5814    }
5815
5816    /// Constraint name from the sibling `.row` file, if one was loaded.
5817    /// Index is original `.nl` row order.
5818    fn constraint_name(&self, i: usize) -> Option<&str> {
5819        self.prob.con_names.get(i).map(String::as_str)
5820    }
5821}
5822
5823/// Per-worker scratch for the parallel Hessian walk. Four buffers of
5824/// `max_tape_n` — the largest tape's node count, tens of entries on the models
5825/// this path is for — so a worker's copy costs about a kilobyte.
5826#[cfg(not(target_arch = "wasm32"))]
5827struct HessScratch {
5828    vals: Vec<f64>,
5829    dot: Vec<f64>,
5830    adj: Vec<f64>,
5831    adj_dot: Vec<f64>,
5832}
5833
5834/// Per-worker scratch for the parallel Jacobian walk: one gradient row of
5835/// length `n` plus the two small tape arenas `gradient_seed_into` fills.
5836#[cfg(not(target_arch = "wasm32"))]
5837#[derive(Debug, Clone)]
5838struct JacScratch {
5839    row_grad: Vec<f64>,
5840    vals: Vec<f64>,
5841    adj: Vec<f64>,
5842}
5843
5844#[cfg(not(target_arch = "wasm32"))]
5845impl JacScratch {
5846    fn new(n: usize, max_tape_n: usize) -> Self {
5847        Self {
5848            row_grad: vec![0.0; n],
5849            vals: vec![0.0; max_tape_n],
5850            adj: vec![0.0; max_tape_n],
5851        }
5852    }
5853}
5854
5855#[cfg(not(target_arch = "wasm32"))]
5856impl HessScratch {
5857    fn new(n: usize) -> Self {
5858        Self {
5859            vals: vec![0.0; n],
5860            dot: vec![0.0; n],
5861            adj: vec![0.0; n],
5862            adj_dot: vec![0.0; n],
5863        }
5864    }
5865}
5866
5867impl NlTnlp {
5868    /// Smallest (row, tape) × color work list that earns a parallel walk.
5869    /// Below it the rayon dispatch costs more than the sweeps it splits, and
5870    /// the serial tape-major walk is also cheaper per unit work because it
5871    /// forwards each tape once rather than once per color.
5872    ///
5873    /// Measured on a banded synthetic (`sin(x_i·x_{i+1}) + x_{i+1}²·x_{i+2}`
5874    /// per row, the model in `parallel_hessian_matches_serial.rs`), serial ->
5875    /// parallel with the gate forced open:
5876    ///
5877    /// | pairs | serial | parallel | ratio |
5878    /// |---|---|---|---|
5879    /// | 400 | 0.028 ms | 0.036 ms | 0.79x |
5880    /// | 1 200 | 0.073 ms | 0.066 ms | 1.11x |
5881    /// | 4 000 | 0.179 ms | 0.112 ms | 1.60x |
5882    /// | 12 000 | 0.344 ms | 0.203 ms | 1.69x |
5883    ///
5884    /// The crossover sits between 400 and 1 200, hence this value.
5885    ///
5886    /// **What that table does not say is how big the win gets.** That model
5887    /// has three Hessian colors, so it has three tasks to hand out however
5888    /// many cores are free, and 1.7x is near its ceiling. The parallelism
5889    /// available here *is* the color count: an AC-SCOPF model with 28 colors
5890    /// runs the same walk 4.9x faster (108.3 ms -> 22.2 ms at 54 859
5891    /// variables, 421.0 ms -> 89.0 ms at 209 755). Gating on pair count alone
5892    /// is therefore conservative in the safe direction — at equal pairs, more
5893    /// colors can only help.
5894    const HESS_PAR_MIN_PAIRS: usize = 1024;
5895
5896    /// Whether the flat Hessian walk runs color-major and in parallel.
5897    ///
5898    /// `POUNCE_NL_PARALLEL_EVAL` overrides the size gate: `0` forces the
5899    /// serial walk, `1` forces the parallel one whatever the size. The two
5900    /// produce bit-identical values, so this is a lever for measurement and
5901    /// for pinning a suspected parallelism bug, not a correctness switch —
5902    /// and `1` is how the threshold below was measured, since the gate would
5903    /// otherwise hide the small end of the curve.
5904    #[cfg(not(target_arch = "wasm32"))]
5905    fn hess_flat_parallel(&self) -> bool {
5906        // More than one color is a hard requirement, not a heuristic: with one
5907        // color there is one task and the inversion is pure overhead.
5908        let multi_color = self.compressed.len() > 1;
5909        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
5910            Ok("0") | Ok("no") | Ok("false") => false,
5911            Ok("1") | Ok("yes") | Ok("true") => multi_color,
5912            _ => multi_color && self.hess_color_index.con_row.len() >= Self::HESS_PAR_MIN_PAIRS,
5913        }
5914    }
5915
5916    /// Smallest Jacobian nonzero count that earns a parallel walk. Same shape
5917    /// of trade as [`Self::HESS_PAR_MIN_PAIRS`], but a cheaper one to cross:
5918    /// the row walk is *not* reordered or duplicated, so the only cost is the
5919    /// dispatch and one length-`n` scratch row per worker.
5920    #[cfg(not(target_arch = "wasm32"))]
5921    const JAC_PAR_MIN_NNZ: usize = 4096;
5922
5923    /// Smallest row count that earns a parallel constraint evaluation. This is
5924    /// the cheapest of the three walks per row — one forward sweep, no
5925    /// gradient, no directional derivative — so the dispatch is a larger share
5926    /// of it and the threshold is the highest of the three.
5927    ///
5928    /// Measured on the banded synthetic, serial -> parallel with the gate
5929    /// forced open: 500 rows 0.34x, 2 000 0.84x, 5 000 1.07x, 8 000 1.53x,
5930    /// 20 000 3.19x, 60 000 4.97x, 200 000 5.78x. The crossover is near 5 000,
5931    /// so the gate sits just under it. Unlike the Hessian, this walk has no
5932    /// color ceiling — every row is its own task — which is why it keeps
5933    /// climbing to ~6x where the Hessian's 3-color model stalls at 1.7x.
5934    #[cfg(not(target_arch = "wasm32"))]
5935    const EVAL_G_PAR_MIN_ROWS: usize = 4096;
5936
5937    /// Whether `eval_g` runs in parallel over rows. Shares
5938    /// `POUNCE_NL_PARALLEL_EVAL` with the other two walks.
5939    #[cfg(not(target_arch = "wasm32"))]
5940    fn eval_g_parallel(&self) -> bool {
5941        // The shared-CSE arm returns earlier; this gate only ever sees the
5942        // flat path.
5943        let big = self.prob.m >= Self::EVAL_G_PAR_MIN_ROWS;
5944        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
5945            Ok("0") | Ok("no") | Ok("false") => false,
5946            Ok("1") | Ok("yes") | Ok("true") => true,
5947            _ => big,
5948        }
5949    }
5950
5951    /// Whether the flat Jacobian walk runs in parallel over row groups.
5952    /// Shares `POUNCE_NL_PARALLEL_EVAL` with the Hessian.
5953    #[cfg(not(target_arch = "wasm32"))]
5954    fn jac_flat_parallel(&self) -> bool {
5955        let big = self.jac_nnz >= Self::JAC_PAR_MIN_NNZ;
5956        match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
5957            Ok("0") | Ok("no") | Ok("false") => false,
5958            Ok("1") | Ok("yes") | Ok("true") => true,
5959            _ => big,
5960        }
5961    }
5962
5963    /// The flat Jacobian, walked in parallel over contiguous groups of rows.
5964    ///
5965    /// Unlike the Hessian this needs no inversion: rows are already
5966    /// independent — each zeroes only its own columns of the gradient scratch,
5967    /// accumulates into them, and writes a contiguous run of `values`. So the
5968    /// work is split by *rows*, each group keeping the serial order inside
5969    /// itself, and every row computes exactly what it computed serially. The
5970    /// results are bit-identical for the stronger reason that nothing was
5971    /// reordered at all.
5972    ///
5973    /// Two things are per-worker rather than per-call: the length-`n` gradient
5974    /// row (the reason this is not simply `par_iter`, since `n` can be tens of
5975    /// thousands and allocating one per row would dwarf the work) and the two
5976    /// small tape arenas. They are pooled on `self` and reused across
5977    /// evaluations.
5978    #[cfg(not(target_arch = "wasm32"))]
5979    fn jac_flat_rows_parallel(&mut self, x_arg: Option<&[Number]>, values: &mut [Number]) {
5980        use rayon::prelude::*;
5981        let n = self.prob.n;
5982        let m = self.prob.m;
5983        let groups = rayon::current_num_threads().clamp(1, m.max(1));
5984        let max_tape_n = self.vals_scratch.len();
5985
5986        // Row -> offset of its run in `values`, so a group can be hnaded one
5987        // contiguous slice. Rebuilt only when the shape changes.
5988        if self.jac_row_off.len() != m + 1 {
5989            self.jac_row_off = Vec::with_capacity(m + 1);
5990            let mut acc = 0u32;
5991            for cols in &self.jac_cols {
5992                self.jac_row_off.push(acc);
5993                acc += cols.len() as u32;
5994            }
5995            self.jac_row_off.push(acc);
5996        }
5997        if self.jac_scratch_pool.len() != groups {
5998            self.jac_scratch_pool = (0..groups)
5999                .map(|_| JacScratch::new(n, max_tape_n))
6000                .collect();
6001        }
6002
6003        // Equal-row groups. Rows differ in width, so this is not a perfect
6004        // balance; rayon is given more groups than threads only when that is
6005        // free, and the measurement below is taken with this split.
6006        let per = m.div_ceil(groups.max(1));
6007        let Self {
6008            prob,
6009            con_tapes,
6010            quad,
6011            jac_cols,
6012            jac_row_off,
6013            jac_scratch_pool,
6014            ..
6015        } = self;
6016        let xs = x_arg.unwrap_or(&prob.x0);
6017
6018        // Hand each group its own contiguous run of `values`.
6019        let mut rest: &mut [Number] = values;
6020        let mut slices: Vec<&mut [Number]> = Vec::with_capacity(groups);
6021        for g in 0..groups {
6022            let end_row = ((g + 1) * per).min(m);
6023            let start_row = (g * per).min(m);
6024            let len = (jac_row_off[end_row] - jac_row_off[start_row]) as usize;
6025            let (head, tail) = rest.split_at_mut(len);
6026            slices.push(head);
6027            rest = tail;
6028        }
6029
6030        jac_scratch_pool
6031            .par_iter_mut()
6032            .zip(slices.into_par_iter())
6033            .enumerate()
6034            .for_each(|(g, (sc, out))| {
6035                let (lo, hi) = ((g * per).min(m), ((g + 1) * per).min(m));
6036                let JacScratch {
6037                    row_grad,
6038                    vals,
6039                    adj,
6040                } = sc;
6041                let mut k = 0usize;
6042                for i in lo..hi {
6043                    for &j in &jac_cols[i] {
6044                        row_grad[j] = 0.0;
6045                    }
6046                    for t in &con_tapes[i] {
6047                        t.gradient_seed_into(xs, 1.0, row_grad, vals, adj);
6048                    }
6049                    if let Some(f) = quad.row_form(i) {
6050                        quad.add_gradient(f, xs, 1.0, row_grad);
6051                    }
6052                    for &(v, c) in &prob.con_linear[i] {
6053                        row_grad[v] += c;
6054                    }
6055                    for &j in &jac_cols[i] {
6056                        out[k] = row_grad[j];
6057                        k += 1;
6058                    }
6059                }
6060            });
6061    }
6062
6063    /// The flat Hessian's constraint block, walked color-major with one rayon
6064    /// task per color.
6065    ///
6066    /// Each task owns its color's `compressed[c]` outright, so nothing is
6067    /// shared mutably and no reduction is needed. Within a color the pairs are
6068    /// visited in ascending (row, tape) order — the order the serial walk
6069    /// contributes them in — so the sums are identical term by term and the
6070    /// two paths agree bit-for-bit, which
6071    /// `parallel_matches_serial_bit_for_bit` pins.
6072    ///
6073    /// The cost of the inversion is forwarding a tape once per color it
6074    /// touches instead of once for all of them (2.9 colors per tape measured
6075    /// on AC-SCOPF). That is paid back by the parallelism and only above
6076    /// `HESS_PAR_MIN_PAIRS`.
6077    #[cfg(not(target_arch = "wasm32"))]
6078    fn hess_flat_colors_parallel(&mut self, x_arg: Option<&[Number]>, lam: &[Number]) {
6079        use rayon::prelude::*;
6080        let Self {
6081            compressed,
6082            seeds,
6083            con_tapes,
6084            hess_color_index: idx,
6085            vals_scratch,
6086            prob,
6087            ..
6088        } = self;
6089        let x = x_arg.unwrap_or(&prob.x0);
6090        let max_tape_n = vals_scratch.len();
6091        compressed.par_iter_mut().enumerate().for_each_init(
6092            || HessScratch::new(max_tape_n),
6093            |sc, (c, out)| {
6094                let (rows, tapes) = idx.con_pairs(c);
6095                let seed = &seeds[c];
6096                let HessScratch {
6097                    vals,
6098                    dot,
6099                    adj,
6100                    adj_dot,
6101                } = sc;
6102                for (&k, &ti) in rows.iter().zip(tapes.iter()) {
6103                    let w = lam[k as usize];
6104                    if w == 0.0 {
6105                        continue;
6106                    }
6107                    let t = &con_tapes[k as usize][ti as usize];
6108                    if t.ops.is_empty() {
6109                        continue;
6110                    }
6111                    t.forward_into(x, vals);
6112                    t.hessian_directional(vals, seed, w, out, dot, adj, adj_dot);
6113                }
6114            },
6115        );
6116    }
6117}
6118
6119impl TNLP for NlTnlp {
6120    /// An `.nl` model carries no callback: the CLI and the browser build
6121    /// read the iteration log, not `IterStats` (gh#965).
6122    fn wants_intermediate_callback(&self) -> bool {
6123        false
6124    }
6125
6126    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
6127        Some(NlpInfo {
6128            n: self.prob.n as Index,
6129            m: self.prob.m as Index,
6130            nnz_jac_g: self.jac_nnz as Index,
6131            nnz_h_lag: self.h_irow.len() as Index,
6132            index_style: IndexStyle::C,
6133        })
6134    }
6135
6136    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
6137        b.x_l.copy_from_slice(&self.prob.x_l);
6138        b.x_u.copy_from_slice(&self.prob.x_u);
6139        if !self.prob.g_l.is_empty() {
6140            b.g_l.copy_from_slice(&self.prob.g_l);
6141            b.g_u.copy_from_slice(&self.prob.g_u);
6142        }
6143        true
6144    }
6145
6146    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
6147        sp.x.copy_from_slice(&self.prob.x0);
6148        // The `.nl` `d` segment supplies initial constraint multipliers
6149        // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
6150        // the engine when `warm_start_init_point yes` — by handing them
6151        // back; `OrigIpoptNlp::get_starting_point` then compresses them into
6152        // the algorithm-side y_c / y_d. Without this the warm start silently
6153        // began from zero multipliers, discarding the parsed duals. (Code
6154        // review 2026-06 item M19.)
6155        if sp.init_lambda {
6156            sp.lambda.copy_from_slice(&self.prob.lambda0);
6157        }
6158        // Bound multipliers come from the `ipopt_zL_in` / `ipopt_zU_in`
6159        // variable suffixes, as in Ipopt's `AmplTNLP::get_starting_point`:
6160        // `z_L = sigma * zL_in`, `z_U = -sigma * zU_in` (sigma = -1 for a
6161        // maximize model) — the inverse of the CLI's `ipopt_zL_out` /
6162        // `ipopt_zU_out`, so a `.sol` result fed back in round-trips. A
6163        // suffix the file does not carry leaves that block to the engine's
6164        // defaults, as before.
6165        if sp.init_z {
6166            let sigma = if self.prob.minimize { 1.0 } else { -1.0 };
6167            let sfx = &self.prob.suffixes;
6168            for (name, out, sign) in [
6169                ("ipopt_zL_in", &mut *sp.z_l, sigma),
6170                ("ipopt_zU_in", &mut *sp.z_u, -sigma),
6171            ] {
6172                // A writer may emit an all-integer suffix as integer-kind.
6173                let vals: Option<Vec<Number>> = sfx.var_real.get(name).cloned().or_else(|| {
6174                    sfx.var_int
6175                        .get(name)
6176                        .map(|v| v.iter().map(|&x| x as Number).collect())
6177                });
6178                if let Some(v) = vals.filter(|v| v.len() == out.len()) {
6179                    for (o, x) in out.iter_mut().zip(v) {
6180                        *o = sign * x;
6181                    }
6182                }
6183            }
6184        }
6185        true
6186    }
6187
6188    /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
6189    /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
6190    /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
6191    /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
6192    /// writes into. Before gh#483 nothing implemented this callback for
6193    /// `.nl` input, so a tagged model reached the solver with the option
6194    /// accepted and *no* scaling applied, silently.
6195    ///
6196    /// Returns `false` (engine falls back to no scaling) when the file
6197    /// declares no `scaling_factor` suffix at all — the same "user
6198    /// supplied nothing" answer as the default `TNLP` impl.
6199    ///
6200    /// AMPL suffix vectors default to **0** for components the model did
6201    /// not tag, and 0 is not a usable scale factor. A zero entry is
6202    /// therefore read as "not tagged" and becomes 1.0, which is what
6203    /// "unlisted components are unscaled" means. Per-variable factors
6204    /// are passed straight through: `OrigIpoptNlp` does not model them
6205    /// and refuses the solve with a message rather than dropping them.
6206    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
6207        const NAME: &str = "scaling_factor";
6208        let sfx = &self.prob.suffixes;
6209        let obj = sfx.obj_real.get(NAME);
6210        let var = sfx.var_real.get(NAME);
6211        let con = sfx.con_real.get(NAME);
6212        let computed = self.curvature_scaling.as_ref();
6213        if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
6214            return false;
6215        }
6216        // Curvature-based factors (gh #703) are the *base*; a
6217        // `scaling_factor` suffix the model actually carries overrides
6218        // them component by component, because an explicit factor from the
6219        // modeller beats one inferred from the coefficients. A model with
6220        // no suffixes is the ordinary case and gets the computed vectors
6221        // whole.
6222        if let Some(sc) = computed {
6223            // The length guards are a `copy_from_slice` panic guard, not a
6224            // policy: `curvature_scaling` sizes both vectors from the same
6225            // `NlProblem` this callback is answering for, so a mismatch is a
6226            // bug upstream, not a model the scheme declines. Declining is the
6227            // *worst* available response to it — `use_*_scaling` stays false,
6228            // the engine reads that as "user supplied nothing", and the run
6229            // proceeds unscaled with the option accepted, which is gh #483
6230            // again. Assert it in debug builds so a mismatch is found here,
6231            // where it is one line, instead of as a slow solve later.
6232            debug_assert_eq!(
6233                sc.x.len(),
6234                req.x_scaling.len(),
6235                "curvature x-scaling sized {} for a {}-variable request",
6236                sc.x.len(),
6237                req.x_scaling.len()
6238            );
6239            debug_assert_eq!(
6240                sc.g.len(),
6241                req.g_scaling.len(),
6242                "curvature g-scaling sized {} for a {}-row request",
6243                sc.g.len(),
6244                req.g_scaling.len()
6245            );
6246            if sc.x.len() == req.x_scaling.len() {
6247                req.x_scaling.copy_from_slice(&sc.x);
6248                *req.use_x_scaling = true;
6249            }
6250            if sc.g.len() == req.g_scaling.len() {
6251                req.g_scaling.copy_from_slice(&sc.g);
6252                *req.use_g_scaling = true;
6253            }
6254        }
6255        // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
6256        // are parsed and ignored), so its entry is the objective scale.
6257        *req.obj_scaling = obj
6258            .and_then(|v| v.first().copied())
6259            .filter(|&s| s != 0.0)
6260            .unwrap_or(1.0);
6261        // A zero entry is AMPL's "untagged" default, not a scale factor.
6262        // An untagged component therefore falls back to the base: the
6263        // curvature factor when one was computed, and an explicit 1.0
6264        // otherwise — explicit because the callback's contract is to fill
6265        // the buffer, not to assume the caller pre-filled it with ones.
6266        if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
6267            for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
6268                if s != 0.0 {
6269                    *slot = s;
6270                } else if computed.is_none() {
6271                    *slot = 1.0;
6272                }
6273            }
6274            *req.use_x_scaling = true;
6275        } else if computed.is_none() {
6276            *req.use_x_scaling = false;
6277        }
6278        if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
6279            for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
6280                if s != 0.0 {
6281                    *slot = s;
6282                } else if computed.is_none() {
6283                    *slot = 1.0;
6284                }
6285            }
6286            *req.use_g_scaling = true;
6287        } else if computed.is_none() {
6288            *req.use_g_scaling = false;
6289        }
6290        true
6291    }
6292
6293    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
6294        // Reuse the shared forward-value arena (sized to `max_tape_n`) so
6295        // each summand sweep allocates nothing — see `Tape::eval_into`.
6296        let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
6297        let mut nl: Number = 0.0;
6298        for t in obj_tapes {
6299            nl += t.eval_into(x, vals);
6300        }
6301        // A recognized objective has no tapes, so the loop above added
6302        // nothing and the form supplies the whole nonlinear part — including
6303        // the linear and constant terms AMPL folded into that tree, which is
6304        // why they are *not* also in `obj_linear` / `obj_constant`.
6305        if let Some(f) = self.quad.objective_form() {
6306            nl += self.quad.value(f, x);
6307        }
6308        let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
6309        let v = self.prob.obj_constant + nl + lin;
6310        let signed = if self.prob.minimize { v } else { -v };
6311        Some(signed)
6312    }
6313
6314    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
6315        grad.fill(0.0);
6316        // Reuse the forward-value / adjoint scratch arenas (sized to
6317        // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
6318        // nothing — see `Tape::gradient_seed_into` (M18).
6319        for t in &self.obj_tapes {
6320            t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
6321        }
6322        if let Some(f) = self.quad.objective_form() {
6323            self.quad.add_gradient(f, x, 1.0, grad);
6324        }
6325        for (i, c) in &self.prob.obj_linear {
6326            grad[*i] += c;
6327        }
6328        if !self.prob.minimize {
6329            for g in grad.iter_mut() {
6330                *g = -*g;
6331            }
6332        }
6333        true
6334    }
6335
6336    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
6337        // Constraint values are the line search's inner loop: on a
6338        // constraint-heavy model (m >> n) this runs ~10x per iteration over
6339        // every summand tape in the problem. Reuse the shared forward-value
6340        // arena so the sweep allocates nothing — the per-summand `Vec` the
6341        // allocating `Tape::eval` used to build was ~20% of `eval_g` on
6342        // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
6343        // `Tape::eval_into`.
6344        let m = self.prob.m;
6345        let con_linear = &self.prob.con_linear;
6346        let quad = &self.quad;
6347        if let Some(h) = &mut self.con_hybrid {
6348            // Shared CSE bodies once for the whole constraint block, then one
6349            // local sweep per summand (pounce#476).
6350            let ConHybrid {
6351                tape,
6352                row_start,
6353                prelude_vals,
6354                local_vals,
6355                ..
6356            } = h;
6357            tape.forward_prelude(x, prelude_vals);
6358            for i in 0..m {
6359                let mut nl: Number = 0.0;
6360                for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6361                    tape.forward_summand(s, x, prelude_vals, local_vals);
6362                    nl += tape.root_value(s, local_vals);
6363                }
6364                if let Some(f) = quad.row_form(i) {
6365                    nl += quad.value(f, x);
6366                }
6367                let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6368                g[i] = nl + lin;
6369            }
6370            return true;
6371        }
6372        // Row `i` reads only `x` and writes only `g[i]`, so this one is
6373        // parallel as written — no inversion, no row-offset bookkeeping, and
6374        // the per-row arithmetic is untouched, which is why the values are
6375        // bit-identical. Only the forward-value arena is per-worker.
6376        #[cfg(not(target_arch = "wasm32"))]
6377        if self.eval_g_parallel() {
6378            use rayon::prelude::*;
6379            let max_tape_n = self.vals_scratch.len();
6380            let con_tapes = &self.con_tapes;
6381            g.par_iter_mut().enumerate().for_each_init(
6382                || vec![0.0; max_tape_n],
6383                |vals, (i, gi)| {
6384                    let mut nl: Number = 0.0;
6385                    for t in &con_tapes[i] {
6386                        nl += t.eval_into(x, vals);
6387                    }
6388                    if let Some(f) = quad.row_form(i) {
6389                        nl += quad.value(f, x);
6390                    }
6391                    let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6392                    *gi = nl + lin;
6393                },
6394            );
6395            return true;
6396        }
6397        let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
6398        for i in 0..m {
6399            let mut nl: Number = 0.0;
6400            for t in &con_tapes[i] {
6401                nl += t.eval_into(x, vals);
6402            }
6403            // A quadratic row's summand range is empty above, so this is its
6404            // whole nonlinear part: one matvec against a constant matrix in
6405            // place of a walk over every monomial's tape.
6406            if let Some(f) = quad.row_form(i) {
6407                nl += quad.value(f, x);
6408            }
6409            let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6410            g[i] = nl + lin;
6411        }
6412        true
6413    }
6414
6415    fn eval_jac_g(
6416        &mut self,
6417        x: Option<&[Number]>,
6418        _new_x: bool,
6419        mode: SparsityRequest<'_>,
6420    ) -> bool {
6421        match mode {
6422            SparsityRequest::Structure { irow, jcol } => {
6423                let mut k = 0;
6424                for i in 0..self.prob.m {
6425                    for &j in &self.jac_cols[i] {
6426                        irow[k] = i as Index;
6427                        jcol[k] = j as Index;
6428                        k += 1;
6429                    }
6430                }
6431                true
6432            }
6433            SparsityRequest::Values { values } => {
6434                let n = self.prob.n;
6435                if self.scratch_row_grad.len() < n {
6436                    self.scratch_row_grad.resize(n, 0.0);
6437                }
6438                // Rows are independent and write disjoint runs of `values`, so
6439                // the parallel walk is the same work in the same order, split
6440                // by row group. Only the flat path: the shared-CSE arm below
6441                // forwards one prelude for the whole block, which is exactly
6442                // the sharing a row split would have to undo.
6443                #[cfg(not(target_arch = "wasm32"))]
6444                if self.con_hybrid.as_ref().filter(|h| h.use_for_jac).is_none()
6445                    && self.jac_flat_parallel()
6446                {
6447                    self.jac_flat_rows_parallel(x, values);
6448                    return true;
6449                }
6450                let Self {
6451                    prob,
6452                    con_tapes,
6453                    con_hybrid,
6454                    quad,
6455                    jac_cols,
6456                    scratch_row_grad,
6457                    vals_scratch,
6458                    adj_scratch,
6459                    ..
6460                } = self;
6461                let xs = x.unwrap_or(&prob.x0);
6462                let mut k = 0;
6463                // Shared-CSE path: the forward sweep over the CSE bodies runs
6464                // once for the whole constraint block instead of once per
6465                // referencing summand. The reverse sweep cannot be shared —
6466                // each row needs its own gradient — so a summand still walks
6467                // its own `prelude_reach` backwards.
6468                if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
6469                    let ConHybrid {
6470                        tape,
6471                        row_start,
6472                        prelude_vals,
6473                        local_vals,
6474                        local_adj,
6475                        prelude_adj,
6476                        ..
6477                    } = h;
6478                    tape.forward_prelude(xs, prelude_vals);
6479                    for i in 0..prob.m {
6480                        for &j in &jac_cols[i] {
6481                            scratch_row_grad[j] = 0.0;
6482                        }
6483                        for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6484                            tape.forward_summand(s, xs, prelude_vals, local_vals);
6485                            tape.gradient_summand(
6486                                s,
6487                                prelude_vals,
6488                                local_vals,
6489                                1.0,
6490                                scratch_row_grad,
6491                                local_adj,
6492                                prelude_adj,
6493                            );
6494                        }
6495                        if let Some(f) = quad.row_form(i) {
6496                            quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6497                        }
6498                        for &(v, c) in &prob.con_linear[i] {
6499                            scratch_row_grad[v] += c;
6500                        }
6501                        for &j in &jac_cols[i] {
6502                            values[k] = scratch_row_grad[j];
6503                            k += 1;
6504                        }
6505                    }
6506                    return true;
6507                }
6508                for i in 0..prob.m {
6509                    for &j in &jac_cols[i] {
6510                        scratch_row_grad[j] = 0.0;
6511                    }
6512                    for t in &con_tapes[i] {
6513                        // Allocation-free reverse-AD per summand tape (M18):
6514                        // reuse the shared forward/adjoint scratch arenas.
6515                        t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
6516                    }
6517                    // `Hx + a` for a quadratic row: one matvec over the
6518                    // row's support, in place of a reverse sweep per
6519                    // monomial. `jac_cols[i]` already covers the form's
6520                    // gradient support, so the scatter lands inside the
6521                    // window zeroed above.
6522                    if let Some(f) = quad.row_form(i) {
6523                        quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6524                    }
6525                    for &(v, c) in &prob.con_linear[i] {
6526                        scratch_row_grad[v] += c;
6527                    }
6528                    for &j in &jac_cols[i] {
6529                        values[k] = scratch_row_grad[j];
6530                        k += 1;
6531                    }
6532                }
6533                true
6534            }
6535        }
6536    }
6537
6538    fn eval_h(
6539        &mut self,
6540        x: Option<&[Number]>,
6541        _new_x: bool,
6542        obj_factor: Number,
6543        lambda: Option<&[Number]>,
6544        _new_lambda: bool,
6545        mode: SparsityRequest<'_>,
6546    ) -> bool {
6547        match mode {
6548            SparsityRequest::Structure { irow, jcol } => {
6549                irow.copy_from_slice(&self.h_irow);
6550                jcol.copy_from_slice(&self.h_jcol);
6551                true
6552            }
6553            SparsityRequest::Values { values } => {
6554                // Kept alongside the resolved slice: the parallel walk below
6555                // resolves it again *after* destructuring `self`, so `x` and
6556                // the buffers it writes borrow different fields and neither
6557                // has to be copied per evaluation.
6558                let x_arg = x;
6559                let x = x_arg.unwrap_or(&self.prob.x0);
6560                values.fill(0.0);
6561
6562                let obj_seed = if self.prob.minimize {
6563                    obj_factor
6564                } else {
6565                    -obj_factor
6566                };
6567                // Coloring path. For each (tape, weight) we do
6568                // one forward pass into `vals_scratch`, then one
6569                // forward-tangent+reverse-over-tangent per color
6570                // touched by that tape. Each pass accumulates a
6571                // weighted contribution of (H_tape · seed_c) into
6572                // `compressed[c]`. After all tapes done, we
6573                // decode each color's compressed vector into the
6574                // sparse `values` array.
6575                for buf in &mut self.compressed {
6576                    buf.fill(0.0);
6577                }
6578
6579                // The constant blocks first: no forward sweep, no
6580                // directional product, no decode — the multipliers are the
6581                // only thing that changed since the model was read, so this
6582                // is one `values[slot] += w · h` pass per live form. Skipped
6583                // wholesale on a model with nothing recognized, so such a
6584                // model does not pay an `O(m)` scan for a structure that is
6585                // empty.
6586                if !self.quad.is_empty() {
6587                    if obj_seed != 0.0 {
6588                        if let Some(f) = self.quad.objective_form() {
6589                            self.quad.accumulate_hessian(f, obj_seed, values);
6590                        }
6591                    }
6592                    if let Some(lam) = lambda {
6593                        for i in 0..self.prob.m {
6594                            let w = lam[i];
6595                            if w == 0.0 {
6596                                continue;
6597                            }
6598                            if let Some(f) = self.quad.row_form(i) {
6599                                self.quad.accumulate_hessian(f, w, values);
6600                            }
6601                        }
6602                    }
6603                }
6604
6605                if obj_seed != 0.0 {
6606                    for (ti, t) in self.obj_tapes.iter().enumerate() {
6607                        if t.ops.is_empty() {
6608                            continue;
6609                        }
6610                        t.forward_into(x, &mut self.vals_scratch);
6611                        for &c in &self.obj_tape_colors[ti] {
6612                            t.hessian_directional(
6613                                &self.vals_scratch,
6614                                &self.seeds[c as usize],
6615                                obj_seed,
6616                                &mut self.compressed[c as usize],
6617                                &mut self.dot_scratch,
6618                                &mut self.adj_scratch,
6619                                &mut self.adj_dot_scratch,
6620                            );
6621                        }
6622                    }
6623                }
6624
6625                // Computed before the match: its scrutinee takes `self` mutably,
6626                // so a guard cannot also read `self`. Absent on wasm32 along
6627                // with the arm it guards — the arm still has to *typecheck*
6628                // there, dead or not, and the method it calls does not exist.
6629                #[cfg(not(target_arch = "wasm32"))]
6630                let hess_flat_parallel = self.hess_flat_parallel();
6631                match (lambda, self.con_hybrid.as_mut()) {
6632                    // Shared-CSE path (issue #557). Per color the prelude's
6633                    // second-order work runs ONCE for the whole constraint
6634                    // block: one forward tangent, then — because
6635                    // reverse-over-tangent is linear in its adjoint seeds —
6636                    // one unit-weight reverse sweep over the λ-weighted
6637                    // adjoints accumulated by every summand of that color.
6638                    // The flat path below repeats both sweeps over the
6639                    // inlined CSE body once per referencing summand.
6640                    (Some(lam), Some(h)) if h.use_for_hess => {
6641                        let ConHybrid {
6642                            tape,
6643                            prelude_vals,
6644                            local_vals_all,
6645                            local_off,
6646                            summand_row,
6647                            hess_color_summands,
6648                            hess_color_reach,
6649                            hess_color_reach_off,
6650                            prelude_dot,
6651                            hess_prelude_adj,
6652                            prelude_adj_dot,
6653                            local_dot,
6654                            local_adj,
6655                            local_adj_dot,
6656                            ..
6657                        } = h;
6658                        // Forward once (values are color-independent):
6659                        // prelude for the block, then each summand of a row
6660                        // with a live multiplier into its packed slice.
6661                        tape.forward_prelude(x, prelude_vals);
6662                        for (si, s) in tape.summands.iter().enumerate() {
6663                            if lam[summand_row[si] as usize] == 0.0 {
6664                                continue;
6665                            }
6666                            tape.forward_summand(
6667                                s,
6668                                x,
6669                                prelude_vals,
6670                                &mut local_vals_all[local_off[si]..local_off[si + 1]],
6671                            );
6672                        }
6673                        for (c, list) in hess_color_summands.iter().enumerate() {
6674                            if !list
6675                                .iter()
6676                                .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
6677                            {
6678                                continue;
6679                            }
6680                            let seed = &self.seeds[c];
6681                            let out = &mut self.compressed[c];
6682                            let creach = &hess_color_reach
6683                                [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
6684                            tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
6685                            for &si in list {
6686                                let si = si as usize;
6687                                let w = lam[summand_row[si] as usize];
6688                                if w == 0.0 {
6689                                    continue;
6690                                }
6691                                tape.hessian_summand_directional(
6692                                    &tape.summands[si],
6693                                    &local_vals_all[local_off[si]..local_off[si + 1]],
6694                                    prelude_dot,
6695                                    seed,
6696                                    w,
6697                                    out,
6698                                    local_dot,
6699                                    local_adj,
6700                                    local_adj_dot,
6701                                    hess_prelude_adj,
6702                                    prelude_adj_dot,
6703                                );
6704                            }
6705                            tape.prelude_reverse_directional(
6706                                prelude_vals,
6707                                prelude_dot,
6708                                creach,
6709                                out,
6710                                hess_prelude_adj,
6711                                prelude_adj_dot,
6712                            );
6713                        }
6714                    }
6715                    // Color-major and parallel when the work is big enough
6716                    // to pay for the dispatch; bit-identical to the walk
6717                    // below, which stays the path every small model takes.
6718                    #[cfg(not(target_arch = "wasm32"))]
6719                    (Some(lam), _) if hess_flat_parallel => {
6720                        self.hess_flat_colors_parallel(x_arg, lam);
6721                    }
6722                    (Some(lam), _) => {
6723                        for k in 0..self.prob.m {
6724                            let w = lam[k];
6725                            if w == 0.0 {
6726                                continue;
6727                            }
6728                            for (ti, t) in self.con_tapes[k].iter().enumerate() {
6729                                if t.ops.is_empty() {
6730                                    continue;
6731                                }
6732                                t.forward_into(x, &mut self.vals_scratch);
6733                                for &c in &self.con_tape_colors[k][ti] {
6734                                    t.hessian_directional(
6735                                        &self.vals_scratch,
6736                                        &self.seeds[c as usize],
6737                                        w,
6738                                        &mut self.compressed[c as usize],
6739                                        &mut self.dot_scratch,
6740                                        &mut self.adj_scratch,
6741                                        &mut self.adj_dot_scratch,
6742                                    );
6743                                }
6744                            }
6745                        }
6746                    }
6747                    (None, _) => {}
6748                }
6749
6750                // Decode each color's compressed Hessian-vector
6751                // result into the lower-triangle `values` array.
6752                for (c, table) in self.decoding.iter().enumerate() {
6753                    let comp = &self.compressed[c];
6754                    for w in table {
6755                        values[w.hess_idx as usize] += comp[w.row as usize];
6756                    }
6757                }
6758                true
6759            }
6760        }
6761    }
6762
6763    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
6764        self.final_x = Some(sol.x.to_vec());
6765        self.final_obj = sol.obj_value;
6766        self.final_lambda = Some(sol.lambda.to_vec());
6767        self.final_z_l = Some(sol.z_l.to_vec());
6768        self.final_z_u = Some(sol.z_u.to_vec());
6769    }
6770
6771    /// Publish the `.col` / `.row` names (captured at load time) under the
6772    /// conventional `idx_names` metadata key, in original `.nl` order. The
6773    /// adapter permutes these into split space (see
6774    /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
6775    /// near-singular Jacobian row as the `mass_balance` equation rather
6776    /// than "row 3" — the model-vs-index gap Lee et al. (2024,
6777    /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
6778    /// model debugging. Declines (returns false) when the model shipped no
6779    /// name files so callers fall back to index labels.
6780    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
6781        let mut any = false;
6782        if !self.prob.var_names.is_empty() {
6783            var.strings
6784                .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
6785            any = true;
6786        }
6787        if !self.prob.con_names.is_empty() {
6788            con.strings
6789                .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
6790            any = true;
6791        }
6792        any
6793    }
6794
6795    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
6796        // A row is linear iff its nonlinear-part expression is the
6797        // identity zero — either left over from initial allocation ("no
6798        // `C<idx>` segment touched this row") or installed by the
6799        // constant-row-body fold in `parse_nl_text`, which shifts a
6800        // variable-free `C<idx>` body into the row bounds precisely so
6801        // that this test is a genuine linearity test and not just an
6802        // identity check (`gh #492`).
6803        for (i, t) in types.iter_mut().enumerate() {
6804            *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
6805                Linearity::Linear
6806            } else {
6807                Linearity::NonLinear
6808            };
6809        }
6810        true
6811    }
6812
6813    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6814        // Global linearity, per the upstream TNLP contract: a variable is
6815        // NonLinear iff it appears in the nonlinear part of the objective
6816        // or of any constraint; otherwise Linear. The parsed `.nl` splits
6817        // every row into a linear part (J/G coefficient list) and a
6818        // nonlinear expression, so the set of nonlinear variables is
6819        // exactly the structural union of `collect_vars` over
6820        // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
6821        // only by a linear part — or not referenced at all — is Linear.
6822        //
6823        let nonlinear = self.nonlinear_var_set();
6824        for (i, t) in types.iter_mut().enumerate() {
6825            *t = if nonlinear.contains(&i) {
6826                Linearity::NonLinear
6827            } else {
6828                Linearity::Linear
6829            };
6830        }
6831        true
6832    }
6833
6834    fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6835        // Objective-scoped variant of `get_variables_linearity`: only
6836        // `obj_nonlinear` contributes. This is what engages the presolve
6837        // auxiliary-elimination safeguard (pounce-presolve H11): a variable
6838        // that is nonlinear in the objective but happens to have a zero
6839        // gradient at the single probe point (e.g. `f = (x - x0)^2`
6840        // warm-started at `x0`) is kept in the objective support instead of
6841        // being mis-classified objective-free and eliminated. A variable
6842        // that is nonlinear only in *constraints* stays `Linear` here, so
6843        // the guard does not block legitimate eliminations of
6844        // objective-free equality blocks (the gas-network case).
6845        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
6846        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
6847        for (i, t) in types.iter_mut().enumerate() {
6848            *t = if nonlinear.contains(&i) {
6849                Linearity::NonLinear
6850            } else {
6851                Linearity::Linear
6852            };
6853        }
6854        true
6855    }
6856
6857    fn get_number_of_nonlinear_variables(&mut self) -> Index {
6858        self.nonlinear_variables().len() as Index
6859    }
6860
6861    fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
6862        let list = self.nonlinear_variables();
6863        if pos_nonlin_vars.len() < list.len() {
6864            return false;
6865        }
6866        pos_nonlin_vars[..list.len()].copy_from_slice(&list);
6867        true
6868    }
6869
6870    fn derivative_proofs(&mut self) -> DerivativeProofs {
6871        // Degree is the whole argument (gh #588, Q6). A body proved
6872        // degree ≤ 1 has a constant gradient and no second derivative; a
6873        // body proved degree 2 has a nonzero second derivative, hence a
6874        // gradient that moves. A body the recognizer refuses is
6875        // `Unknown` — and `Unknown` must stay `Unknown`, because the
6876        // refusal is structural, not a finding of nonlinearity.
6877        fn proof(affine: Option<bool>) -> DerivativeProof {
6878            match affine {
6879                Some(true) => DerivativeProof::Constant,
6880                Some(false) => DerivativeProof::Varying,
6881                None => DerivativeProof::Unknown,
6882            }
6883        }
6884        let obj_affine = self.prob.obj_nonlinear.provably_affine();
6885        let jac: Vec<DerivativeProof> = self
6886            .prob
6887            .con_nonlinear
6888            .iter()
6889            .map(|b| proof(b.provably_affine()))
6890            .collect();
6891
6892        // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
6893        // makes `∇²L` a non-constant function of `λ` — this is the QCQP
6894        // case, and it is exactly the assertion Ipopt would honour and
6895        // get wrong (§4 Lever 2). Otherwise every row must be *proved*
6896        // affine and the objective *proved* degree ≤ 2, at which point
6897        // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
6898        // reuse on `σ` because the restoration phase passes a different
6899        // one.
6900        let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
6901            DerivativeProof::Varying
6902        } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
6903            DerivativeProof::Constant
6904        } else {
6905            DerivativeProof::Unknown
6906        };
6907
6908        DerivativeProofs {
6909            grad_f: proof(obj_affine),
6910            hessian,
6911            jac,
6912        }
6913    }
6914}
6915
6916/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
6917pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
6918    let prob = read_nl_file(path)?;
6919    Ok(Rc::new(RefCell::new(NlTnlp::new(prob))))
6920}
6921
6922#[cfg(test)]
6923mod tests {
6924    /// `.nl` text for `min sum_i exp(x_i) + x_i^2` whose sum is a left-deep
6925    /// chain of `2n - 1` binary `o0`, wrapped in `wraps` pairs of unary `o16`
6926    /// (negation, an even count so the value is unchanged). The `o0` chain is
6927    /// read as one n-ary `Sum`; the negations are what stays genuinely deep.
6928    fn deep_sum_nl(n: usize, wraps: usize) -> String {
6929        let mut l = vec![
6930            "g3 1 1 0".to_string(),
6931            format!(" {n} 1 1 0 1"),
6932            " 0 1".into(),
6933            " 0 0".into(),
6934            format!(" 0 {n} 0"),
6935            " 0 0 0 1 0".into(),
6936            " 0 0 0 0 0".into(),
6937            format!(" {n} {n}"),
6938            " 0 0".into(),
6939            " 0 0 0 0 0".into(),
6940            "C0".into(),
6941            "n0".into(),
6942            "O0 0".into(),
6943        ];
6944        l.extend(std::iter::repeat_n("o16".to_string(), 2 * wraps));
6945        l.extend(std::iter::repeat_n("o0".to_string(), 2 * n - 1));
6946        for i in 0..n {
6947            l.extend([
6948                "o44".into(),
6949                format!("v{i}"),
6950                "o2".into(),
6951                format!("v{i}"),
6952                format!("v{i}"),
6953            ]);
6954        }
6955        l.extend(["r".into(), "4 0.5".into(), "b".into()]);
6956        l.extend((0..n).map(|_| "0 -1.0 1.0".to_string()));
6957        l.push(format!("k{}", n - 1));
6958        l.extend((0..n - 1).map(|i| (i + 1).to_string()));
6959        l.push(format!("J0 {n}"));
6960        l.extend((0..n).map(|i| format!("{i} 1.0")));
6961        l.push(format!("G0 {n}"));
6962        l.extend((0..n).map(|i| format!("{i} 0.0")));
6963        l.join("\n") + "\n"
6964    }
6965
6966    /// Run `f` on a thread with the stack the CLI and `pounce-py` provide,
6967    /// declared to the reader the way they declare it.
6968    fn on_big_stack(f: impl FnOnce() + Send + 'static) {
6969        std::thread::Builder::new()
6970            .stack_size(1 << 30)
6971            .spawn(move || {
6972                set_parse_stack_budget(Some(1 << 30));
6973                f();
6974                set_parse_stack_budget(None);
6975            })
6976            .unwrap()
6977            .join()
6978            .unwrap();
6979    }
6980
6981    /// A model whose objective is the last of `len` defined variables, each
6982    /// referencing the previous: `V_0 = x0^2`, `V_k = V_{k-1} + x0`, so the
6983    /// objective is `x0^2 + (len - 1) x0`. Every `V` segment is one level
6984    /// deep to the parser; the chain is `len` deep to anything that walks
6985    /// through it.
6986    fn v_chain_nl(len: usize) -> String {
6987        let mut l = vec![
6988            "g3 1 1 0".to_string(),
6989            " 1 1 1 0 1".into(),
6990            " 0 1".into(),
6991            " 0 0".into(),
6992            " 0 1 0".into(),
6993            " 0 0 0 1 0".into(),
6994            format!(" 0 0 {len} 0 0"),
6995            " 1 1".into(),
6996            " 0 0".into(),
6997            " 0 0 0 0 0".into(),
6998        ];
6999        l.extend(["V1 0 0".into(), "o5".into(), "v0".into(), "n2".into()]);
7000        for k in 1..len {
7001            l.extend([
7002                format!("V{} 0 0", k + 1),
7003                "o0".into(),
7004                format!("v{k}"),
7005                "v0".into(),
7006            ]);
7007        }
7008        l.extend(["C0".into(), "n0".into()]);
7009        l.extend(["O0 0".into(), format!("v{len}")]);
7010        l.extend(["r".into(), "4 0.5".into(), "b".into(), "0 -1.0 1.0".into()]);
7011        l.extend(["k0".into(), "J0 1".into(), "0 1.0".into()]);
7012        l.extend(["G0 1".into(), "0 0.0".into()]);
7013        l.join("\n") + "\n"
7014    }
7015
7016    /// gh#986 review item 9: a chain of defined variables is counted by the
7017    /// depth guard. Each `V` segment is shallow to the parser, so the first
7018    /// pass let a chain of any length through, and every pass that walks
7019    /// through a `Cse` (`collect_vars`, the tape builder, drop glue) then
7020    /// recursed once per link -- an abort on an ordinary stack. On a thread
7021    /// that declared no stack (the default budget) a chain far past the guard
7022    /// is now a clean `Err`.
7023    #[test]
7024    fn gh986_a_long_defined_variable_chain_is_refused_not_overflowed() {
7025        let limit = parse_depth_limit() as usize;
7026        let err = parse_nl_text(&v_chain_nl(limit + 50))
7027            .err()
7028            .expect("a V chain past the guard must be an Err");
7029        assert!(err.contains("defined") && err.contains("levels"), "{err}");
7030        // A short chain on the same thread is fine, and evaluates.
7031        let prob = parse_nl_text(&v_chain_nl(limit / 2)).expect("inside the guard");
7032        let x = [0.5];
7033        let want = 0.25 + (limit / 2 - 1) as f64 * 0.5;
7034        let got = eval_expr(&prob.obj_expr(), &x);
7035        assert!((got - want).abs() < 1e-9, "{got} vs {want}");
7036    }
7037
7038    /// gh#986 review item 9: the depth of a `V` body is memoized, so a long
7039    /// chain inside the guard costs time linear in the chain (the first
7040    /// pass's `expr_tree_depth` re-walked the whole chain on every segment).
7041    #[test]
7042    fn gh986_a_long_defined_variable_chain_inside_the_guard_parses() {
7043        on_big_stack(|| {
7044            // Two levels per link (the `o0` and the `Cse` reference), just
7045            // inside this thread's limit.
7046            let len = (parse_depth_limit() as usize - 100) / 2;
7047            let t = std::time::Instant::now();
7048            let prob = parse_nl_text(&v_chain_nl(len)).expect("inside the guard on a big stack");
7049            let got = eval_expr(&prob.obj_expr(), &[0.5]);
7050            let want = 0.25 + (len - 1) as f64 * 0.5;
7051            assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
7052            // Linear. With only `expr_tree_depth` memoized, the parse-time
7053            // quadratic passes still re-walked the chain per segment and this
7054            // ran past 60 s (measured, test profile); seeded, ~50 ms.
7055            let took = t.elapsed();
7056            eprintln!("v chain {len}: parsed and evaluated in {took:?}");
7057            assert!(took.as_secs() < 30, "{took:?}");
7058        });
7059    }
7060
7061    /// gh#986 review item 9: a thread that declares no stack is assumed to
7062    /// have Rust's default thread stack (wasm: 1 MiB), not the CLI's 1 GiB.
7063    #[test]
7064    fn gh986_an_undeclared_stack_gets_the_default_assumption() {
7065        std::thread::spawn(|| {
7066            set_parse_stack_budget(None);
7067            assert_eq!(
7068                parse_depth_limit() as usize,
7069                DEFAULT_ASSUMED_STACK / PARSE_LEVEL_BYTES
7070            );
7071            assert!((parse_depth_limit() as usize) < MAX_PARSE_DEPTH as usize);
7072        })
7073        .join()
7074        .unwrap();
7075    }
7076
7077    /// gh#986: a left-deep `o0` chain of any length is read as one n-ary
7078    /// `Sum` — one level deep — so the issue's n = 20 000 (39 999 nested
7079    /// `o0`) parses, and so does 100k, with the same value as the nested
7080    /// binary tree would have had.
7081    #[test]
7082    fn gh986_long_o0_chain_is_read_as_a_flat_sum() {
7083        on_big_stack(|| {
7084            for n in [5_000usize, 20_000, 100_000] {
7085                let prob = parse_nl_text(&deep_sum_nl(n, 0)).expect("a long o0 sum must parse");
7086                let x: Vec<f64> = (0..n).map(|i| (i % 7) as f64 * 0.1).collect();
7087                let want: f64 = x.iter().map(|v| v.exp() + v * v).sum();
7088                let tree = prob.obj_expr();
7089                assert!(expr_tree_depth(&tree, &Default::default()) < 16, "n = {n}");
7090                let got = eval_expr(&tree, &x);
7091                assert!((got - want).abs() <= 1e-9 * want.abs(), "{got} vs {want}");
7092            }
7093        });
7094    }
7095
7096    /// gh#986: the guard follows the stack the thread declares, so a thread
7097    /// with an ordinary 8 MiB stack refuses deep nesting cleanly — in an
7098    /// unoptimised build (the worst frame size) exactly as in an optimised
7099    /// one — instead of overflowing it. This is the reliability the
7100    /// `parse_nl_text` SIGBUS/SIGSEGV reports lacked.
7101    #[test]
7102    fn gh986_guard_follows_the_declared_stack() {
7103        let stack = 8 << 20;
7104        std::thread::Builder::new()
7105            .stack_size(stack)
7106            .spawn(move || {
7107                set_parse_stack_budget(Some(stack));
7108                assert_eq!(parse_depth_limit() as usize, stack / PARSE_LEVEL_BYTES);
7109                let msg = parse_nl_text(&deep_sum_nl(4, 20_000))
7110                    .err()
7111                    .expect("40 000 levels cannot fit 8 MiB");
7112                assert!(msg.contains("levels"), "{msg}");
7113                // and a shallow model on the same thread is fine
7114                assert!(parse_nl_text(&deep_sum_nl(4, 50)).is_ok());
7115                set_parse_stack_budget(None);
7116            })
7117            .unwrap()
7118            .join()
7119            .unwrap();
7120    }
7121
7122    /// gh#986: nesting that really is deep (negations) past the guard is
7123    /// refused with an `Err`, at any depth, instead of overflowing the stack;
7124    /// nesting just inside the guard still parses. Both on the stack the
7125    /// callers provide, in debug as in release — the guard's headroom is
7126    /// what this pins.
7127    #[test]
7128    fn gh986_deep_nesting_is_a_clean_error_past_the_guard_and_parses_inside_it() {
7129        on_big_stack(|| {
7130            let deep = parse_nl_text(&deep_sum_nl(4, 30_000));
7131            let msg = deep.err().expect("past the guard must be an Err");
7132            assert!(msg.contains("levels"), "{msg}");
7133            // Just inside the limit this thread's declared stack gives
7134            // (`MAX_PARSE_DEPTH` in an optimised build, 32 768 in an
7135            // unoptimised one, whose worst-case frame is 32 KiB).
7136            let wraps = (parse_depth_limit() as usize - 100) / 2;
7137            assert!(parse_nl_text(&deep_sum_nl(4, wraps)).is_ok());
7138        });
7139    }
7140
7141    use super::*;
7142
7143    /// Compile-time guarantee for the batched-solve path (pounce#126):
7144    /// a parsed problem and the TNLP built from it must be movable to a
7145    /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
7146    /// `!Send` state) into the `Expr` DAG / tape pipeline.
7147    #[test]
7148    fn nl_problem_and_tnlp_are_send() {
7149        fn assert_send<T: Send>() {}
7150        assert_send::<NlProblem>();
7151        assert_send::<NlTnlp>();
7152        assert_send::<Expr>();
7153    }
7154
7155    /// `variant()` patches starting point / bounds on a clone and
7156    /// validates override lengths; the base instance is untouched.
7157    #[test]
7158    fn variant_overrides_bounds_and_x0() {
7159        let p = parse_nl_text(SIMPLE).expect("parse");
7160        let base = NlTnlp::new(p);
7161        let var = base
7162            .variant(&NlVariation {
7163                x0: Some(vec![3.0, 4.0]),
7164                x_l: Some(vec![-1.0, -2.0]),
7165                x_u: Some(vec![5.0, 6.0]),
7166                ..Default::default()
7167            })
7168            .expect("variant");
7169        let mut var = var;
7170        let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
7171        let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
7172        assert!(var.get_bounds_info(BoundsInfo {
7173            x_l: &mut x_l,
7174            x_u: &mut x_u,
7175            g_l: &mut g_l,
7176            g_u: &mut g_u,
7177        }));
7178        assert_eq!(x_l, [-1.0, -2.0]);
7179        assert_eq!(x_u, [5.0, 6.0]);
7180        let mut x = [0.0; 2];
7181        let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
7182        assert!(var.get_starting_point(StartingPoint {
7183            init_x: true,
7184            x: &mut x,
7185            init_z: false,
7186            z_l: &mut zl,
7187            z_u: &mut zu,
7188            init_lambda: false,
7189            lambda: &mut lam,
7190        }));
7191        assert_eq!(x, [3.0, 4.0]);
7192        // Base keeps its parsed (free) bounds.
7193        assert!(base.problem().x_l[0] < -1.0e18);
7194        // Length mismatch is an error, not a panic.
7195        assert!(
7196            base.variant(&NlVariation {
7197                x0: Some(vec![1.0]),
7198                ..Default::default()
7199            })
7200            .is_err()
7201        );
7202    }
7203
7204    /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
7205    /// Header values:
7206    ///   line 2: n=2 m=0 num_obj=1 0 0
7207    ///   line 3: 0 1   (1 nonlinear objective)
7208    ///   line 4: 0 0
7209    ///   line 5: 0 2 0 (nonlinear vars in obj=2)
7210    ///   line 6: 0 0 0 1
7211    ///   line 7: 0 0 0 0 0
7212    ///   line 8: 0 0   (no Jacobian nonzeros, no linear obj)
7213    ///   line 9: 0 0
7214    ///   line 10: 0 0 0 0 0
7215    /// Then `O0 0` followed by an expression tree:
7216    /// `(x0 - 1)^2 + (x1 - 2)^2` =
7217    ///   o0
7218    ///     o5 (o1 v0 n1) n2
7219    ///     o5 (o1 v1 n2) n2
7220    /// Then `b` segment: free for both.
7221    /// gh#987: Gay's ordering places nonlinear integers at the END of each
7222    /// nonlinear block and linear ones at the end of the model.
7223    #[test]
7224    fn discrete_census_locates_nonlinear_and_linear_integer_columns() {
7225        // n = 10: nlvb = 2 (1 int), con-only 3 (1 int), obj-only 1 (1 int),
7226        // then linear, ending in nbv = 1, niv = 1.
7227        let d = DiscreteCensus {
7228            binary: 1,
7229            integer: 1,
7230            nl_both: 1,
7231            nl_cons: 1,
7232            nl_objs: 1,
7233        };
7234        let c = NlCounts {
7235            nl_cons: 1,
7236            nl_objs: 1,
7237            nl_vars_cons: 5,
7238            nl_vars_objs: 6,
7239            nl_vars_both: 2,
7240        };
7241        assert_eq!(d.total(), 5);
7242        assert_eq!(d.integer_columns(10, Some(c)), Some(vec![1, 4, 5, 8, 9]));
7243        // Without the line-5 census the nonlinear columns are unknowable.
7244        assert_eq!(d.integer_columns(10, None), None);
7245        // Linear-only integers need no census.
7246        let lin = DiscreteCensus {
7247            binary: 2,
7248            ..Default::default()
7249        };
7250        assert_eq!(lin.integer_columns(4, None), Some(vec![2, 3]));
7251        // Inconsistent counts: more nonlinear-in-both integers than columns.
7252        let bad = DiscreteCensus {
7253            nl_both: 3,
7254            ..Default::default()
7255        };
7256        assert_eq!(bad.integer_columns(10, Some(c)), None);
7257    }
7258
7259    const SIMPLE: &str = "g3 0 1 0
72602 0 1 0 0
72610 1
72620 0
72630 2 0
72640 0 0 1
72650 0 0 0 0
72660 0
72670 0
72680 0 0 0 0
7269O0 0
7270o0
7271o5
7272o1
7273v0
7274n1
7275n2
7276o5
7277o1
7278v1
7279n2
7280n2
7281b
72823
72833
7284";
7285
7286    #[test]
7287    fn parses_simple_quadratic() {
7288        let p = parse_nl_text(SIMPLE).expect("parse");
7289        assert_eq!(p.n, 2);
7290        assert_eq!(p.m, 0);
7291        assert_eq!(p.num_obj, 1);
7292        // f(0,0) = 1 + 4 = 5
7293        let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
7294        assert!((f - 5.0).abs() < 1e-12);
7295        // f(1,2) = 0
7296        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7297        assert!(f.abs() < 1e-12);
7298    }
7299
7300    #[test]
7301    fn gradient_matches_analytic() {
7302        let p = parse_nl_text(SIMPLE).expect("parse");
7303        let x = [0.5, 1.0];
7304        let mut g = [0.0_f64; 2];
7305        grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
7306        // d/dx0 = 2*(x0-1) = -1.0
7307        // d/dx1 = 2*(x1-2) = -2.0
7308        assert!((g[0] - (-1.0)).abs() < 1e-12);
7309        assert!((g[1] - (-2.0)).abs() < 1e-12);
7310    }
7311
7312    /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
7313    /// with global semantics so the presolve auxiliary-elimination
7314    /// safeguard actually engages. Pre-fix the default trait stub returned
7315    /// `false` and left the slice untouched, so a variable that is
7316    /// nonlinear in the objective but zero-gradient at the probe point
7317    /// could be wrongly eliminated.
7318    ///
7319    /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
7320    /// of the objective (NonLinear); x1 appears only in the linear part
7321    /// (Linear).
7322    #[test]
7323    fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
7324        // (x0 - 1)^2
7325        let obj_nl = Expr::Binary(
7326            BinOp::Pow,
7327            Box::new(Expr::Binary(
7328                BinOp::Sub,
7329                Box::new(Expr::Var(0)),
7330                Box::new(Expr::Const(1.0)),
7331            )),
7332            Box::new(Expr::Const(2.0)),
7333        );
7334        let prob = NlProblem {
7335            src: None,
7336            cse_bodies: Vec::new(),
7337            n: 2,
7338            m: 0,
7339            num_obj: 1,
7340            minimize: true,
7341            obj_nonlinear: NlBody::Tree(obj_nl),
7342            obj_linear: vec![(1, 3.0)],
7343            obj_constant: 0.0,
7344            con_nonlinear: vec![],
7345            con_linear: vec![],
7346            x_l: vec![f64::NEG_INFINITY; 2],
7347            x_u: vec![f64::INFINITY; 2],
7348            g_l: vec![],
7349            g_u: vec![],
7350            x0: vec![0.0; 2],
7351            lambda0: vec![],
7352            suffixes: NlSuffixes::default(),
7353            imported_funcs: vec![],
7354            ampl_options: vec![],
7355            nl_counts: None,
7356            n_discrete: None,
7357            var_names: vec![],
7358            con_names: vec![],
7359        };
7360        let mut tnlp = NlTnlp::new(prob);
7361        let mut types = vec![Linearity::Linear; 2];
7362        let ok = tnlp.get_variables_linearity(&mut types);
7363        // Pre-fix: default stub returns false (slice untouched).
7364        assert!(
7365            ok,
7366            "get_variables_linearity must report it filled the slice"
7367        );
7368        assert!(
7369            matches!(types[0], Linearity::NonLinear),
7370            "x0 is nonlinear in the objective"
7371        );
7372        assert!(
7373            matches!(types[1], Linearity::Linear),
7374            "x1 appears only in the linear part"
7375        );
7376    }
7377
7378    /// Objective-scoped linearity must NOT inherit constraint
7379    /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
7380    /// (constraint tape) but linear w.r.t. the objective, so the presolve
7381    /// H11 guard must not treat it as objective-coupled — that was the CI
7382    /// regression where every gas-network variable (nonlinear in the flow
7383    /// equations, absent from the linear objective) blocked Phase-0
7384    /// elimination.
7385    #[test]
7386    fn objective_variables_linearity_ignores_constraint_nonlinearity() {
7387        // x0^2
7388        let con_nl = Expr::Binary(
7389            BinOp::Pow,
7390            Box::new(Expr::Var(0)),
7391            Box::new(Expr::Const(2.0)),
7392        );
7393        let prob = NlProblem {
7394            src: None,
7395            cse_bodies: Vec::new(),
7396            n: 2,
7397            m: 1,
7398            num_obj: 1,
7399            minimize: true,
7400            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
7401            obj_linear: vec![(1, 3.0)],
7402            obj_constant: 0.0,
7403            con_nonlinear: vec![NlBody::Tree(con_nl)],
7404            con_linear: vec![vec![]],
7405            x_l: vec![f64::NEG_INFINITY; 2],
7406            x_u: vec![f64::INFINITY; 2],
7407            g_l: vec![4.0],
7408            g_u: vec![4.0],
7409            x0: vec![0.0; 2],
7410            lambda0: vec![0.0],
7411            suffixes: NlSuffixes::default(),
7412            imported_funcs: vec![],
7413            ampl_options: vec![],
7414            nl_counts: None,
7415            n_discrete: None,
7416            var_names: vec![],
7417            con_names: vec![],
7418        };
7419        let mut tnlp = NlTnlp::new(prob);
7420
7421        let mut global = vec![Linearity::Linear; 2];
7422        assert!(tnlp.get_variables_linearity(&mut global));
7423        assert!(
7424            matches!(global[0], Linearity::NonLinear),
7425            "global tags see x0's constraint nonlinearity"
7426        );
7427
7428        let mut obj = vec![Linearity::NonLinear; 2];
7429        assert!(tnlp.get_objective_variables_linearity(&mut obj));
7430        assert!(
7431            matches!(obj[0], Linearity::Linear),
7432            "x0 is linear w.r.t. the objective despite the nonlinear constraint"
7433        );
7434        assert!(
7435            matches!(obj[1], Linearity::Linear),
7436            "x1 is linear everywhere"
7437        );
7438    }
7439
7440    /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
7441    /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
7442    /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
7443    /// constraints, and both variables nonlinear in the objective only.
7444    #[test]
7445    fn header_census_is_parsed() {
7446        let p = parse_nl_text(SIMPLE).expect("parse");
7447        let c = p.nl_counts.expect("SIMPLE has a well-formed header");
7448        assert_eq!(c.nl_cons, 0);
7449        assert_eq!(c.nl_objs, 1);
7450        assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
7451        assert_eq!(c.nonlinear_vars(), 2);
7452    }
7453
7454    /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
7455    /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
7456    /// as the overlapping one: disjoint sets add, and `max` would be wrong
7457    /// for them.
7458    #[test]
7459    fn nonlinear_var_total_uses_inclusion_exclusion() {
7460        let c = |vc, vo, vb| NlCounts {
7461            nl_cons: 0,
7462            nl_objs: 0,
7463            nl_vars_cons: vc,
7464            nl_vars_objs: vo,
7465            nl_vars_both: vb,
7466        };
7467        // Fully shared: the same 5 variables in both.
7468        assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
7469        // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
7470        // not the `max(nlvc, nlvo) = 1` a naive reading gives.
7471        assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
7472        // Partial overlap: 4 + 3 - 2.
7473        assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
7474        // Nonsense header: saturates instead of underflowing.
7475        assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
7476    }
7477
7478    /// A header that does not carry the documented fields records no
7479    /// census at all rather than a guess of zero — "no nonlinear
7480    /// variables" is a claim, and a truncated header has not made it.
7481    #[test]
7482    fn short_header_line_records_no_census() {
7483        // Line 5 with two fields instead of `nlvc nlvo nlvb`.
7484        let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
7485        assert_ne!(txt, SIMPLE, "the substitution must have applied");
7486        let p = parse_nl_text(&txt).expect("parse");
7487        assert!(p.nl_counts.is_none());
7488    }
7489
7490    /// `get_number_of_nonlinear_variables` used to be the trait default,
7491    /// `-1` ("assume everything is nonlinear"). It now answers from the
7492    /// trees, and `get_list_of_nonlinear_variables` agrees with it.
7493    ///
7494    /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
7495    #[test]
7496    fn nonlinear_variable_list_excludes_linear_columns() {
7497        let obj_nl = Expr::Binary(
7498            BinOp::Pow,
7499            Box::new(Expr::Binary(
7500                BinOp::Sub,
7501                Box::new(Expr::Var(0)),
7502                Box::new(Expr::Const(1.0)),
7503            )),
7504            Box::new(Expr::Const(2.0)),
7505        );
7506        let parts = NlProblemParts {
7507            minimize: true,
7508            objective: obj_nl,
7509            obj_constant: 0.0,
7510            constraints: vec![],
7511            x_l: vec![-1e19; 2],
7512            x_u: vec![1e19; 2],
7513            x0: vec![0.0; 2],
7514            g_l: vec![],
7515            g_u: vec![],
7516            var_names: vec![],
7517            con_names: vec![],
7518        };
7519        let prob = NlProblem::from_expressions(parts).expect("build");
7520        assert!(
7521            prob.nl_counts.is_none(),
7522            "a model built in memory has no header to read"
7523        );
7524        let mut tnlp = NlTnlp::new(prob);
7525        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
7526        let mut list = [-1 as Index; 2];
7527        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7528        assert_eq!(list[0], 0);
7529    }
7530
7531    /// When the header says every variable is nonlinear the walk is
7532    /// skipped, and the answer is the same one the walk would give for
7533    /// `SIMPLE` (both variables appear in the objective's nonlinear part).
7534    #[test]
7535    fn all_nonlinear_header_short_circuits_to_n() {
7536        let p = parse_nl_text(SIMPLE).expect("parse");
7537        assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
7538        let mut tnlp = NlTnlp::new(p);
7539        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
7540        let mut list = [-1 as Index; 2];
7541        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7542        assert_eq!(list, [0, 1]);
7543    }
7544
7545    /// The list must not be written when the caller's slice is too small —
7546    /// the contract's `false` return, not a panic.
7547    #[test]
7548    fn nonlinear_variable_list_declines_a_short_slice() {
7549        let p = parse_nl_text(SIMPLE).expect("parse");
7550        let mut tnlp = NlTnlp::new(p);
7551        let mut list = [-1 as Index; 1];
7552        assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
7553        assert_eq!(list, [-1]);
7554    }
7555
7556    /// `min x0^2 + x1^2  s.t.  x0 + x1 = 1`.
7557    /// One equality constraint with a purely linear Jacobian — exercises
7558    /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
7559    /// kind 4).
7560    ///
7561    /// Header layout:
7562    ///   line 1: g3 0 1 0
7563    ///   line 2: 2 1 1 0 0   (n=2, m=1, num_obj=1)
7564    ///   line 3: 0 1         (1 nonlinear obj, 0 nonlinear cons)
7565    ///   line 4: 0 0
7566    ///   line 5: 0 2 0       (nonlinear vars in obj=2)
7567    ///   line 6: 0 0 0 1
7568    ///   line 7: 0 0 0 0 0
7569    ///   line 8: 2 0         (Jacobian nnz=2, no linear obj)
7570    ///   line 9: 0 0
7571    ///   line 10: 0 0 0 0 0
7572    /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
7573    /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
7574    const EQ_LIN: &str = "g3 0 1 0
75752 1 1 0 0
75760 1
75770 0
75780 2 0
75790 0 0 1
75800 0 0 0 0
75812 0
75820 0
75830 0 0 0 0
7584C0
7585n0
7586O0 0
7587o0
7588o5
7589v0
7590n2
7591o5
7592v1
7593n2
7594r
75954 1
7596b
75973
75983
7599k1
76002
7601J0 2
76020 1
76031 1
7604";
7605
7606    #[test]
7607    fn parses_constrained_problem() {
7608        let p = parse_nl_text(EQ_LIN).expect("parse");
7609        assert_eq!(p.n, 2);
7610        assert_eq!(p.m, 1);
7611        // r-segment kind 4 (equality with rhs=1).
7612        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7613        assert!((p.g_u[0] - 1.0).abs() < 1e-12);
7614        // J-row 0: x0 (coef 1), x1 (coef 1).
7615        assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
7616    }
7617
7618    #[test]
7619    fn malformed_j_variable_index_is_parse_error_not_panic() {
7620        // Code review L32: a J-segment entry's variable (column) index was
7621        // pushed into con_linear unchecked, so an out-of-range index (here 5
7622        // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
7623        // constraint evaluation. It must instead surface as a clean parse
7624        // error, consistent with the existing `J<row> out of range` check.
7625        let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
7626        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7627        let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
7628        assert!(err.contains("out of range"), "unexpected error: {err}");
7629    }
7630
7631    #[test]
7632    fn out_of_range_x_segment_index_is_parse_error() {
7633        // Same strictness for the initial-primal `x` segment: an index past
7634        // `n` used to be silently dropped; now it is a parse error, so the
7635        // four index-bearing segments (J/G/x/d) behave consistently.
7636        let bad = format!("{EQ_LIN}x1\n5 0.5\n");
7637        let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
7638        assert!(err.contains("out of range"), "unexpected error: {err}");
7639    }
7640
7641    // ---------------------------------------------------------------
7642    // gh #492 — a constant `C<i>` body folds into the row bounds.
7643    //
7644    // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
7645    // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
7646    // constant-body rows to fold.
7647    // ---------------------------------------------------------------
7648
7649    /// Linearity is a *linearity* test, not "did a `C` segment touch this
7650    /// row". A row whose body is the bare constant `3` is affine.
7651    #[test]
7652    fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
7653        // `x0 + x1 + 3 = 1`  ⇔  `x0 + x1 = -2`.
7654        let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7655        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7656        let p = parse_nl_text(&nl).expect("parse");
7657
7658        assert!(
7659            p.con_nonlinear[0].is_trivially_zero(),
7660            "the constant body must be replaced by the identity zero, got {:?}",
7661            p.con_nonlinear[0]
7662        );
7663        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7664        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7665
7666        let mut lin = [Linearity::NonLinear];
7667        let mut t = NlTnlp::new(p);
7668        assert!(t.get_constraints_linearity(&mut lin));
7669        assert_eq!(lin[0], Linearity::Linear);
7670    }
7671
7672    /// The shift must be exact, not merely "linear now": the folded model
7673    /// and the hand-folded one must be the same problem, row body for row
7674    /// body and bound for bound. That is what keeps feasibility, the
7675    /// active set, and the duals unchanged.
7676    #[test]
7677    fn folding_a_row_constant_gives_the_hand_folded_problem() {
7678        // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
7679        let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7680        let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
7681        assert_ne!(offset, EQ_LIN);
7682        assert_ne!(folded, EQ_LIN);
7683
7684        let a = parse_nl_text(&offset).expect("parse offset form");
7685        let b = parse_nl_text(&folded).expect("parse folded form");
7686        assert_eq!(a.g_l, b.g_l);
7687        assert_eq!(a.g_u, b.g_u);
7688        assert_eq!(a.con_linear, b.con_linear);
7689
7690        // And the row *values* agree pointwise, which is the property the
7691        // duals ride on: `g(x) - g_l` is the same residual either way.
7692        let mut ga = [0.0];
7693        let mut gb = [0.0];
7694        let x = [0.75, -1.25];
7695        assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
7696        assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
7697        assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
7698    }
7699
7700    /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
7701    /// constant as `n3`, and AMPL emits such trees when a expression
7702    /// collapses without being re-simplified.
7703    #[test]
7704    fn a_row_body_that_evaluates_to_a_constant_folds_too() {
7705        // `C0` = `1 + 2`.
7706        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
7707        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7708        let p = parse_nl_text(&nl).expect("parse");
7709        assert!(p.con_nonlinear[0].is_trivially_zero());
7710        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7711        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7712    }
7713
7714    /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
7715    /// "absent", not "a very large number". Shifting one turns it into a
7716    /// real bound and invents a constraint that is not in the model.
7717    ///
7718    /// The constants here are deliberately huge. An everyday `3` is
7719    /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
7720    /// presence guard would go unnoticed at ordinary magnitudes and then
7721    /// bite on a model that scales its rows. The guard is what makes the
7722    /// sentinel untouchable at *any* magnitude.
7723    #[test]
7724    fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
7725        // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
7726        // bound. Shifting the lower sentinel would leave `-9e18`, a real
7727        // bound, so the row would gain a floor the model never stated.
7728        let nl = EQ_LIN
7729            .replace("C0\nn0\n", "C0\nn-1e18\n")
7730            .replace("r\n4 1\n", "r\n1 1\n");
7731        let p = parse_nl_text(&nl).expect("parse");
7732        assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
7733        assert!(
7734            !lower_bound_present(p.g_l[0]),
7735            "the absent-lower sentinel became a real bound: {}",
7736            p.g_l[0]
7737        );
7738
7739        // The mirror case: a positive constant on a lower-bounded row is
7740        // the one that would pull the *upper* sentinel below 1e19.
7741        let nl = EQ_LIN
7742            .replace("C0\nn0\n", "C0\nn1e18\n")
7743            .replace("r\n4 1\n", "r\n2 1\n");
7744        let p = parse_nl_text(&nl).expect("parse");
7745        assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
7746        assert!(
7747            !upper_bound_present(p.g_u[0]),
7748            "the absent-upper sentinel became a real bound: {}",
7749            p.g_u[0]
7750        );
7751    }
7752
7753    /// A row body that mentions a variable is not a constant, however
7754    /// simple it looks. Folding it would delete the term.
7755    #[test]
7756    fn a_row_body_with_a_variable_is_not_folded() {
7757        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
7758        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7759        let p = parse_nl_text(&nl).expect("parse");
7760        assert!(
7761            !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
7762            "a row in x0 was folded away: {:?}",
7763            p.con_nonlinear[0]
7764        );
7765        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
7766        assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
7767    }
7768
7769    /// A variable-free body whose value is not finite is left in place. It
7770    /// makes the row infeasible (or ill-posed) and that is the solver's
7771    /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
7772    /// corrupt the bound pair and take every downstream presence test with
7773    /// it.
7774    #[test]
7775    fn a_non_finite_constant_row_body_is_not_folded() {
7776        // `C0` = `log(-1)` = NaN.
7777        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
7778        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7779        let p = parse_nl_text(&nl).expect("parse");
7780        assert!(
7781            !p.con_nonlinear[0].is_trivially_zero(),
7782            "a NaN body was folded into the bounds"
7783        );
7784        assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
7785        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7786    }
7787
7788    /// An imported-function call is not a parse-time constant even with
7789    /// constant arguments — it is resolved to a shared library much later
7790    /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
7791    /// rather than guess. The fold must decline before it evaluates.
7792    #[test]
7793    fn a_constant_argument_funcall_row_body_is_not_folded() {
7794        // Declare one imported function and call it with a literal.
7795        let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
7796        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7797        let p = parse_nl_text(&nl).expect("parse");
7798        assert!(
7799            matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
7800            "expected the funcall to survive the fold, got {:?}",
7801            p.con_nonlinear[0]
7802        );
7803        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
7804    }
7805
7806    #[test]
7807    fn k_segment_nonstandard_count_is_parse_error_at_source() {
7808        // Code review L35: the `k` (Jacobian column-count) segment header
7809        // declares how many count lines follow — `k<count>` — and the
7810        // standard value is n-1. The parser used to *assume* n-1 and ignore
7811        // the header, so a file declaring a different count read the wrong
7812        // number of data lines, desynced the segment stream, and failed far
7813        // downstream with a confusing error (or silently mis-parsed). With
7814        // the declared count now read and validated, a nonstandard count is
7815        // a clear parse error at its source. Here EQ_LIN has n=2 (expected
7816        // count 1); rewrite its `k1` + one count line to `k0`.
7817        let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
7818        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7819        let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
7820        assert!(
7821            err.contains("k-segment declares"),
7822            "expected a clear k-segment count error, got: {err}"
7823        );
7824    }
7825
7826    #[test]
7827    fn get_starting_point_returns_nl_initial_duals() {
7828        // Code review 2026-06 item M19: the `.nl` `d` segment supplies
7829        // initial constraint multipliers. They are parsed into `lambda0`,
7830        // but `get_starting_point` previously ignored them — so a
7831        // `warm_start_init_point yes` solve silently began from zero duals.
7832        // `get_starting_point` must hand the parsed duals back when the
7833        // engine requests them (`init_lambda`), and leave the buffer
7834        // untouched when it does not.
7835        let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
7836        let p = parse_nl_text(&nl).expect("parse");
7837        // gh#1033: a `d` entry is an AMPL dual, so it is stored negated.
7838        assert_eq!(p.lambda0, vec![-2.5], "the `d` segment fills lambda0");
7839
7840        let mut t = NlTnlp::new(p);
7841        let info = t.get_nlp_info().unwrap();
7842        let (n, m) = (info.n as usize, info.m as usize);
7843
7844        // Warm-start request: init_lambda = true → the parsed `.nl` duals
7845        // must be returned (pre-fix this stayed zero).
7846        let mut x = vec![0.0; n];
7847        let mut z_l = vec![0.0; n];
7848        let mut z_u = vec![0.0; n];
7849        let mut lambda = vec![0.0; m];
7850        assert!(t.get_starting_point(StartingPoint {
7851            init_x: true,
7852            x: &mut x,
7853            init_z: false,
7854            z_l: &mut z_l,
7855            z_u: &mut z_u,
7856            init_lambda: true,
7857            lambda: &mut lambda,
7858        }));
7859        assert_eq!(
7860            lambda,
7861            vec![-2.5],
7862            "a warm start must use the `.nl` initial duals, not zero"
7863        );
7864
7865        // No warm-start request: the multiplier buffer is left alone (the
7866        // engine owns its default), so honoring the flag does not clobber it.
7867        let mut lambda_untouched = vec![7.0; m];
7868        assert!(t.get_starting_point(StartingPoint {
7869            init_x: true,
7870            x: &mut x,
7871            init_z: false,
7872            z_l: &mut z_l,
7873            z_u: &mut z_u,
7874            init_lambda: false,
7875            lambda: &mut lambda_untouched,
7876        }));
7877        assert_eq!(
7878            lambda_untouched,
7879            vec![7.0],
7880            "without init_lambda the multiplier buffer must be untouched"
7881        );
7882    }
7883
7884    /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
7885    /// unconstrained, `n` variables. The trailing product puts a nonzero
7886    /// in row 0 of *every* Hessian column, which is the shape that used
7887    /// to force the greedy coloring to hand out one color per variable.
7888    fn dense_row_objective_nl(n: usize) -> String {
7889        let mut s = String::new();
7890        s.push_str("g3 1 1 0\n");
7891        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
7892        s.push_str(" 0 1\n 0 0\n");
7893        s.push_str(&format!(" {n} {n} {n}\n"));
7894        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
7895        s.push_str(&format!(" 0 {n}\n"));
7896        s.push_str(" 0 0\n 0 0 0 0 0\n");
7897        // objective
7898        s.push_str("O0 0\n");
7899        s.push_str(&format!("o54\n{}\n", n + 1));
7900        for j in 0..n {
7901            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
7902        }
7903        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
7904        for j in 0..n {
7905            s.push_str(&format!("v{j}\n"));
7906        }
7907        // start, bounds, gradient
7908        s.push_str(&format!("x{n}\n"));
7909        for j in 0..n {
7910            s.push_str(&format!("{j} 0.5\n"));
7911        }
7912        s.push_str("b\n");
7913        for _ in 0..n {
7914            s.push_str("3\n");
7915        }
7916        s.push_str(&format!("G0 {n}\n"));
7917        for j in 0..n {
7918            s.push_str(&format!("{j} 0.0\n"));
7919        }
7920        s
7921    }
7922
7923    /// Same shape as [`dense_row_objective_nl`], but the coupling term
7924    /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
7925    /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
7926    /// magnitude makes the dense column ill-scaled — the case where
7927    /// reading its entries out of its own pass costs real digits.
7928    fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
7929        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
7930        let mut s = String::new();
7931        s.push_str("g3 1 1 0\n");
7932        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
7933        s.push_str(" 0 1\n 0 0\n");
7934        s.push_str(&format!(" {n} {n} {n}\n"));
7935        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
7936        s.push_str(&format!(" 0 {n}\n"));
7937        s.push_str(" 0 0\n 0 0 0 0 0\n");
7938        s.push_str("O0 0\n");
7939        s.push_str(&format!("o54\n{}\n", n + 1));
7940        for j in 0..n {
7941            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
7942        }
7943        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
7944        for j in 0..n {
7945            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
7946        }
7947        s.push_str(&format!("x{n}\n"));
7948        for j in 0..n {
7949            s.push_str(&format!("{j} 0.5\n"));
7950        }
7951        s.push_str("b\n");
7952        for _ in 0..n {
7953            s.push_str("3\n");
7954        }
7955        s.push_str(&format!("G0 {n}\n"));
7956        for j in 0..n {
7957            s.push_str(&format!("{j} 0.0\n"));
7958        }
7959        s
7960    }
7961
7962    /// Locate a model in the benchmark corpus, or `None` if the corpus is
7963    /// not on this machine.
7964    ///
7965    /// The corpus is ~2 GB and deliberately outside the checkout (see
7966    /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
7967    /// no-op rather than fail. That is a real limitation — a check that
7968    /// silently does nothing is how the gap below went unnoticed in the
7969    /// first place — so anything using this must also be covered by a
7970    /// synthetic case that always runs.
7971    fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
7972        let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
7973        let p = std::path::PathBuf::from(root).join(rel);
7974        p.is_file().then_some(p)
7975    }
7976
7977    /// The corpus check the dense-column optimization never got.
7978    ///
7979    /// `cho_parmest` is the model whose certificate the optimization cost,
7980    /// and it could not have caught it: the model is 4.3 MB and lives in
7981    /// the benchmark data set, not in the repository, so validating
7982    /// against the in-repo `.nl` fixtures said nothing about it. This test
7983    /// closes that by checking the corpus directly wherever the corpus
7984    /// exists, which is every machine and CI job that runs the benchmarks.
7985    ///
7986    /// The assertion is the one that matters and the one that was never
7987    /// made: whatever the guard leaves peeled must decode to the same
7988    /// Hessian as peeling nothing. Against the pre-fix code this fails —
7989    /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
7990    #[test]
7991    fn cho_parmest_decodes_to_its_unpeeled_reference() {
7992        let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
7993            eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
7994            return;
7995        };
7996        let p = read_nl_file(&path).expect("read cho_parmest");
7997        let n = p.n;
7998        let mut t = NlTnlp::new(p);
7999        // The guard vetoes 7 of cho's 12 peeled columns, and putting those
8000        // seven dense rows back into the conflict graph costs the coloring
8001        // outright: it goes from 17 colors to 9010, and no column clears the
8002        // density threshold afterwards, so the model ends up fully unpeeled.
8003        // That is the price of the certificate on this model, and it is worth
8004        // knowing rather than assuming the other five survive.
8005        assert!(t.peeled_cols.is_empty());
8006
8007        let info = t.get_nlp_info().unwrap();
8008        let nnz = info.nnz_h_lag as usize;
8009        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8010        assert!(t.eval_h(
8011            None,
8012            true,
8013            1.0,
8014            None,
8015            true,
8016            SparsityRequest::Structure {
8017                irow: &mut irow,
8018                jcol: &mut jcol
8019            }
8020        ));
8021        // Evaluate away from `x0` with non-uniform multipliers. The damage
8022        // is point-dependent, and `x0` is close to where it is least
8023        // visible: only 5 entries move there, against 48,931 here. Note the
8024        // guard's own probe runs at `x0` — it fires anyway because it tests
8025        // a bound on what a pass *could* lose, not the damage it happens to
8026        // commit at one point. That is the property that makes it robust,
8027        // and this asymmetry is worth keeping in front of anyone who
8028        // retunes it.
8029        let x: Vec<f64> = t
8030            .prob
8031            .x0
8032            .iter()
8033            .enumerate()
8034            .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
8035            .collect();
8036        let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
8037
8038        let mut got = vec![0.0_f64; nnz];
8039        assert!(t.eval_h(
8040            Some(&x),
8041            true,
8042            1.0,
8043            Some(&lambda),
8044            true,
8045            SparsityRequest::Values { values: &mut got }
8046        ));
8047
8048        t.recolor(&vec![true; n]);
8049        assert!(t.peeled_cols.is_empty());
8050        let mut want = vec![0.0_f64; nnz];
8051        assert!(t.eval_h(
8052            Some(&x),
8053            true,
8054            1.0,
8055            Some(&lambda),
8056            true,
8057            SparsityRequest::Values { values: &mut want }
8058        ));
8059
8060        let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
8061        let mut worst = 0.0_f64;
8062        let mut at = 0usize;
8063        for k in 0..nnz {
8064            let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8065            if rel > worst {
8066                worst = rel;
8067                at = k;
8068            }
8069        }
8070        assert!(
8071            worst <= 1e-13,
8072            "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
8073             {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
8074             out of a pass it cannot be read out of.",
8075            irow[at],
8076            jcol[at],
8077            got[at],
8078            want[at]
8079        );
8080
8081        // The check above passes trivially on correct code, because the
8082        // guard leaves cho fully unpeeled and so compares a configuration
8083        // against itself. It only has teeth against a regression. So prove
8084        // the guard is doing necessary work rather than assuming it: put
8085        // the peels back the way the pre-fix reader had them and confirm
8086        // the Hessian really does come apart.
8087        t.recolor(&vec![false; n]);
8088        assert!(
8089            !t.peeled_cols.is_empty(),
8090            "restoring the unvetoed coloring must peel again"
8091        );
8092        let mut unguarded = vec![0.0_f64; nnz];
8093        assert!(t.eval_h(
8094            Some(&x),
8095            true,
8096            1.0,
8097            Some(&lambda),
8098            true,
8099            SparsityRequest::Values {
8100                values: &mut unguarded
8101            }
8102        ));
8103        let mut bad = 0usize;
8104        let mut worst_unguarded = 0.0_f64;
8105        for k in 0..nnz {
8106            let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8107            if rel > 1e-13 {
8108                bad += 1;
8109            }
8110            worst_unguarded = worst_unguarded.max(rel);
8111        }
8112        // Two different counts get quoted about this model and they measure
8113        // different things: 48,931 of the 96,000 entries differ from the
8114        // uncompressed reference *at all*, down to the last bit, while 88
8115        // exceed 1e-13 relative. The second is the one worth asserting on.
8116        assert!(
8117            bad >= 50 && worst_unguarded > 1e-11,
8118            "peeling cho_parmest unguarded should damage the entries the guard \
8119             exists to protect (measured: 88 entries past 1e-13 relative, \
8120             worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
8121             this fires, the model or the corpus changed and the guard's \
8122             calibration should be re-derived rather than the bound relaxed."
8123        );
8124        eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
8125    }
8126
8127    /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
8128    /// the dense variable is `x_{n-1}` instead of `x_0`:
8129    /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
8130    ///
8131    /// The index matters, and it is the whole reason this helper exists.
8132    /// With the dense variable at 0 every coupling entry is stored as
8133    /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
8134    /// out of column 0's own pass whether or not peeling is on, so the two
8135    /// paths are bit-identical and no test built on that shape can tell
8136    /// them apart. Putting the dense variable last stores them as
8137    /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
8138    /// back from column `n-1`'s pass by symmetry, carrying that pass's
8139    /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
8140    /// That is the category every one of `cho_parmest`'s 48,931 damaged
8141    /// entries fell into.
8142    fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
8143        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
8144        let d = n - 1;
8145        let mut s = String::new();
8146        s.push_str("g3 1 1 0\n");
8147        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8148        s.push_str(" 0 1\n 0 0\n");
8149        s.push_str(&format!(" {n} {n} {n}\n"));
8150        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8151        s.push_str(&format!(" 0 {n}\n"));
8152        s.push_str(" 0 0\n 0 0 0 0 0\n");
8153        s.push_str("O0 0\n");
8154        s.push_str(&format!("o54\n{}\n", n + 1));
8155        for j in 0..n {
8156            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8157        }
8158        s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
8159        for j in 0..d {
8160            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8161        }
8162        s.push_str(&format!("x{n}\n"));
8163        for j in 0..n {
8164            s.push_str(&format!("{j} 0.5\n"));
8165        }
8166        s.push_str("b\n");
8167        for _ in 0..n {
8168            s.push_str("3\n");
8169        }
8170        s.push_str(&format!("G0 {n}\n"));
8171        for j in 0..n {
8172            s.push_str(&format!("{j} 0.0\n"));
8173        }
8174        s
8175    }
8176
8177    /// A well-scaled dense column is still peeled, and the entries read
8178    /// out of its pass are exact — the property the peel guard must not
8179    /// cost us.
8180    #[test]
8181    fn a_well_scaled_dense_column_is_still_peeled() {
8182        let n = 200;
8183        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
8184        let t = NlTnlp::new(p);
8185        assert_eq!(
8186            t.peeled_cols,
8187            vec![0],
8188            "a dense column that costs no accuracy must stay peeled"
8189        );
8190        assert!(
8191            t.seeds.len() <= 4,
8192            "peeling should keep the color count at O(1), got {}",
8193            t.seeds.len()
8194        );
8195    }
8196
8197    /// An ill-scaled dense column must be un-peeled and colored the
8198    /// ordinary way, so its small entries come back to full precision.
8199    ///
8200    /// This test covers the *guard*, not the damage. It asserts that a
8201    /// column spanning 12 orders is un-peeled and that its entries are then
8202    /// exact — and it is worth being precise that it does not, and cannot,
8203    /// show what peeling would have cost, because on this model peeling
8204    /// costs nothing: force the peel through and every entry still comes
8205    /// back bit-identical to its analytic weight. The Hessian here is
8206    /// constant and each entry is a single product, so there is no
8207    /// accumulation for a large-magnitude pass to pollute.
8208    ///
8209    /// Reproducing the actual digit loss takes a model whose entries are
8210    /// summed through shared intermediates, which is why the demonstration
8211    /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
8212    /// real model rather than a synthetic one.
8213    #[test]
8214    fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
8215        let n = 200;
8216        let span = 12.0;
8217        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8218        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
8219        let mut t = NlTnlp::new(p);
8220
8221        assert!(
8222            t.peeled_cols.is_empty(),
8223            "a dense column spanning {span} orders must not be peeled; got {:?}",
8224            t.peeled_cols
8225        );
8226
8227        let info = t.get_nlp_info().unwrap();
8228        let nnz = info.nnz_h_lag as usize;
8229        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8230        assert!(t.eval_h(
8231            None,
8232            true,
8233            1.0,
8234            None,
8235            true,
8236            SparsityRequest::Structure {
8237                irow: &mut irow,
8238                jcol: &mut jcol
8239            }
8240        ));
8241        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8242        let mut vals = vec![0.0_f64; nnz];
8243        assert!(t.eval_h(
8244            Some(&x),
8245            true,
8246            1.0,
8247            None,
8248            true,
8249            SparsityRequest::Values { values: &mut vals }
8250        ));
8251
8252        // Every coupling entry must be its weight to full relative
8253        // precision. Peeled, the smallest of them came back with a
8254        // relative error near 1e-4.
8255        let mut checked = 0;
8256        for k in 0..nnz {
8257            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8258            if i == j {
8259                continue;
8260            }
8261            assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8262            checked += 1;
8263            let want = w(i);
8264            assert!(
8265                (vals[k] - want).abs() <= 1e-13 * want,
8266                "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
8267                vals[k],
8268                (vals[k] - want).abs() / want
8269            );
8270        }
8271        assert_eq!(checked, n - 1);
8272    }
8273
8274    /// Whatever survives the peel guard must decode to the same Hessian
8275    /// that not peeling at all produces — every entry, not just the
8276    /// objective.
8277    ///
8278    /// This exists because the original dense-column optimization was
8279    /// validated by running the repository's `.nl` fixtures and comparing
8280    /// objective value and exit status, which was doubly blind: not one of
8281    /// the 60 fixtures has a Hessian row dense enough to peel anything, so
8282    /// the decode path under test never executed, and even had it executed,
8283    /// the defect cost digits in the multipliers while leaving the
8284    /// objective intact. So the instrument has to be the assembled Hessian
8285    /// and the input has to actually peel — hence `peeled_any` below, which
8286    /// fails if the sweep ever goes vacuous the way the fixture suite
8287    /// silently did.
8288    ///
8289    /// What this catches is a *decode* fault: an entry recovered from the
8290    /// wrong pass, which is wrong by O(1). It would not have caught the
8291    /// `cho_parmest` stall, because these synthetic models lose no
8292    /// precision under peeling at all (see
8293    /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
8294    /// precision half is covered against the real model in
8295    /// `cho_parmest_decodes_to_its_unpeeled_reference`.
8296    #[test]
8297    fn a_peeled_decode_matches_an_unpeeled_reference() {
8298        // `last = true` puts the dense variable at `n-1`, so the coupling
8299        // entries are stored with the peeled column as their *row* — the
8300        // only shape in which peeling and not peeling read an entry out of
8301        // different passes, and so the only shape that can detect a
8302        // difference at all. See `weighted_dense_last_col_nl`.
8303        let cases = [
8304            (200, 0.0, false),
8305            (200, 2.0, false),
8306            (200, 0.0, true),
8307            (200, 1.0, true),
8308            (200, 2.0, true),
8309            (400, 3.0, true),
8310            (600, 0.0, true),
8311        ];
8312        let mut peeled_any = false;
8313        let mut worst = 0.0_f64;
8314
8315        for &(n, span, last) in &cases {
8316            let text = if last {
8317                weighted_dense_last_col_nl(n, span)
8318            } else {
8319                weighted_dense_row_objective_nl(n, span)
8320            };
8321            let p = parse_nl_text(&text).expect("parse");
8322            let mut t = NlTnlp::new(p);
8323            let peeled = t.peeled_cols.clone();
8324            peeled_any |= !peeled.is_empty();
8325
8326            let info = t.get_nlp_info().unwrap();
8327            let nnz = info.nnz_h_lag as usize;
8328            let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8329            assert!(t.eval_h(
8330                None,
8331                true,
8332                1.0,
8333                None,
8334                true,
8335                SparsityRequest::Structure {
8336                    irow: &mut irow,
8337                    jcol: &mut jcol
8338                }
8339            ));
8340            let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
8341
8342            let mut got = vec![0.0_f64; nnz];
8343            assert!(t.eval_h(
8344                Some(&x),
8345                true,
8346                1.0,
8347                None,
8348                true,
8349                SparsityRequest::Values { values: &mut got }
8350            ));
8351
8352            // Same object, same tapes, same point — only the coloring
8353            // differs, so any disagreement is the decode path.
8354            t.recolor(&vec![true; n]);
8355            assert!(
8356                t.peeled_cols.is_empty(),
8357                "a fully vetoed model must peel nothing"
8358            );
8359            let mut want = vec![0.0_f64; nnz];
8360            assert!(t.eval_h(
8361                Some(&x),
8362                true,
8363                1.0,
8364                None,
8365                true,
8366                SparsityRequest::Values { values: &mut want }
8367            ));
8368
8369            for k in 0..nnz {
8370                let scale = want[k].abs().max(f64::MIN_POSITIVE);
8371                let rel = (got[k] - want[k]).abs() / scale;
8372                worst = worst.max(rel);
8373                assert!(
8374                    rel <= 1e-13,
8375                    "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
8376                     unpeeled reference {:e} (relative error {rel:e})",
8377                    irow[k],
8378                    jcol[k],
8379                    got[k],
8380                    want[k]
8381                );
8382            }
8383        }
8384
8385        assert!(
8386            peeled_any,
8387            "no case peeled anything, so this test proved nothing about the \
8388             decode path — the exact way the fixture suite missed the bug"
8389        );
8390        assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
8391    }
8392
8393    /// `peel_veto` bars a column from the peel set, and the row it puts
8394    /// back into the conflict structure costs the colors it used to save.
8395    #[test]
8396    fn a_vetoed_column_is_colored_the_ordinary_way() {
8397        let n = 300;
8398        // One dense row plus a diagonal: column 0 touches every row.
8399        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8400        pairs.extend((1..n).map(|i| (i, 0)));
8401        pairs.sort_unstable();
8402
8403        let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8404        assert!(peeled[0], "the dense column should peel by default");
8405        assert!(
8406            colors_peeled <= 4,
8407            "peeling should collapse the count, got {colors_peeled}"
8408        );
8409
8410        let mut veto = vec![false; n];
8411        veto[0] = true;
8412        let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
8413        assert!(!peeled[0], "a vetoed column must not be peeled");
8414        assert!(
8415            colors_vetoed > colors_peeled,
8416            "un-peeling restores row 0's conflicts, so colors must rise: \
8417             {colors_vetoed} vs {colors_peeled}"
8418        );
8419    }
8420
8421    /// A single dense Hessian row must not blow the coloring up to one
8422    /// color per variable. It used to: every column shares row 0, so no
8423    /// two columns could be colored alike, and `seeds` / `compressed`
8424    /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
8425    /// per `eval_h` on a Hessian holding only ~2n nonzeros.
8426    #[test]
8427    fn a_dense_hessian_row_does_not_explode_the_coloring() {
8428        let n = 200;
8429        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8430        let t = NlTnlp::new(p);
8431        // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
8432        // for j >= 1 from the coupling term.
8433        assert_eq!(t.h_irow.len(), 2 * n - 1);
8434        assert!(
8435            t.seeds.len() <= 4,
8436            "one dense row should cost one extra color, not n; got {} colors for n={n}",
8437            t.seeds.len()
8438        );
8439        assert_eq!(t.seeds.len(), t.compressed.len());
8440    }
8441
8442    /// Peeling changes *which* directional product recovers an entry, so
8443    /// the recovered Hessian must still be exactly right — including the
8444    /// entries read out of a peeled column's pass by symmetry.
8445    #[test]
8446    fn peeled_dense_column_still_recovers_the_exact_hessian() {
8447        let n = 200;
8448        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8449        let mut t = NlTnlp::new(p);
8450        let info = t.get_nlp_info().unwrap();
8451        let nnz = info.nnz_h_lag as usize;
8452
8453        let mut irow = vec![0_i32; nnz];
8454        let mut jcol = vec![0_i32; nnz];
8455        assert!(t.eval_h(
8456            None,
8457            true,
8458            1.0,
8459            None,
8460            true,
8461            SparsityRequest::Structure {
8462                irow: &mut irow,
8463                jcol: &mut jcol
8464            }
8465        ));
8466
8467        // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
8468        //   H[0,0] = 2 + 2 = 4;  H[j,j] = 2 (j >= 1);  H[j,0] = 1 (j >= 1).
8469        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8470        let obj_factor = 2.5;
8471        let mut vals = vec![0.0_f64; nnz];
8472        assert!(t.eval_h(
8473            Some(&x),
8474            true,
8475            obj_factor,
8476            None,
8477            true,
8478            SparsityRequest::Values { values: &mut vals }
8479        ));
8480
8481        let mut seen_diag = 0;
8482        let mut seen_coupling = 0;
8483        for k in 0..nnz {
8484            let (i, j) = (irow[k] as usize, jcol[k] as usize);
8485            let want = if i == 0 && j == 0 {
8486                4.0
8487            } else if i == j {
8488                seen_diag += 1;
8489                2.0
8490            } else {
8491                assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8492                seen_coupling += 1;
8493                1.0
8494            } * obj_factor;
8495            assert!(
8496                (vals[k] - want).abs() < 1e-12,
8497                "H[{i},{j}] = {}, want {want}",
8498                vals[k]
8499            );
8500        }
8501        assert_eq!(seen_diag, n - 1);
8502        assert_eq!(seen_coupling, n - 1);
8503    }
8504
8505    /// The peeling threshold must leave ordinary sparse models on
8506    /// exactly the coloring they had before: a banded Hessian colors by
8507    /// its bandwidth, with nothing peeled.
8508    #[test]
8509    fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
8510        let n = 400;
8511        let pairs: Vec<(usize, usize)> = (0..n)
8512            .flat_map(|j| {
8513                let mut v = vec![(j, j)];
8514                if j + 1 < n {
8515                    v.push((j + 1, j));
8516                }
8517                v
8518            })
8519            .collect();
8520        let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8521        assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
8522        assert!(
8523            n_colors <= 3,
8524            "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
8525        );
8526        assert!(var_color.iter().all(|&c| c != u32::MAX));
8527    }
8528
8529    /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
8530    /// scattered through an otherwise diagonal `n`-variable problem.
8531    /// A plain coloring needs exactly `size` colors no matter how many
8532    /// blocks there are, because the blocks share no rows.
8533    fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
8534        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8535        let stride = n / blocks;
8536        for b in 0..blocks {
8537            let base = b * stride;
8538            for i in 0..size {
8539                for j in 0..=i {
8540                    if i != j {
8541                        pairs.push((base + i, base + j));
8542                    }
8543                }
8544            }
8545        }
8546        pairs
8547    }
8548
8549    /// Many medium-degree columns must not be peeled.
8550    ///
8551    /// Regression: selecting candidates by "degree > 16x average" and then
8552    /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
8553    /// The columns that miss the cut stay in the conflict structure, so the
8554    /// base color count is untouched and the singleton colors are pure
8555    /// addition — these two patterns colored to 306 and 290 against a plain
8556    /// walk's 50 and 34.
8557    #[test]
8558    fn thousands_of_medium_degree_cols_are_not_peeled() {
8559        for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
8560            let pairs = disjoint_blocks(n, blocks, size);
8561            let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8562            let n_peeled = peeled.iter().filter(|&&p| p).count();
8563            assert_eq!(
8564                n_peeled, 0,
8565                "degree-{size} columns do not pay for a singleton color \
8566                 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
8567            );
8568            assert!(
8569                n_colors <= size + 1,
8570                "disjoint {size}x{size} blocks color by block size regardless \
8571                 of block count; got {n_colors} for n={n}, blocks={blocks}"
8572            );
8573        }
8574    }
8575
8576    /// The pay-for-itself rule must not throw away the case peeling exists
8577    /// for: a handful of genuinely dense rows still get peeled, and still
8578    /// collapse the coloring.
8579    #[test]
8580    fn a_few_truly_dense_rows_are_still_peeled() {
8581        let n = 5_000;
8582        let dense_rows = 4;
8583        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8584        for d in 0..dense_rows {
8585            for j in 0..n {
8586                if j != d {
8587                    pairs.push((j.max(d), j.min(d)));
8588                }
8589            }
8590        }
8591        let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8592        let n_peeled = peeled.iter().filter(|&&p| p).count();
8593        assert_eq!(n_peeled, dense_rows, "every full row should peel");
8594        assert!(
8595            n_colors <= dense_rows + 2,
8596            "peeling {dense_rows} full rows should leave a diagonal remainder, \
8597             got {n_colors} colors"
8598        );
8599    }
8600
8601    /// The cap still binds, and when it does the kept columns are the
8602    /// highest-degree ones.
8603    #[test]
8604    fn peeling_is_capped_and_keeps_the_worst_offenders() {
8605        let n = 20_000;
8606        let dense_rows = 400;
8607        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8608        for d in 0..dense_rows {
8609            // Row d touches the first (n - d) columns, so degree strictly
8610            // decreases with d and the ordering is unambiguous.
8611            for j in 0..(n - d) {
8612                if j != d {
8613                    pairs.push((j.max(d), j.min(d)));
8614                }
8615            }
8616        }
8617        let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8618        let n_peeled = peeled.iter().filter(|&&p| p).count();
8619        assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
8620        assert!(
8621            (0..MAX_PEELED_COLS).all(|d| peeled[d]),
8622            "the {MAX_PEELED_COLS} densest rows are the ones kept"
8623        );
8624    }
8625
8626    /// Header line 0 is `g<count> <opt0> ...`; the option words are the
8627    /// model's own and a solver echoes them into the `.sol` `Options`
8628    /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
8629    #[test]
8630    fn header_option_words_are_kept_verbatim() {
8631        let p = parse_nl_text(EQ_LIN).expect("parse");
8632        assert_eq!(p.ampl_options, vec![0, 1, 0]);
8633    }
8634
8635    /// A count that does not match the words present must not be
8636    /// guessed at — the writer falls back rather than emit a wrong block.
8637    #[test]
8638    fn a_truncated_option_list_is_dropped_not_padded() {
8639        let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
8640        let p = parse_nl_text(&text).expect("parse");
8641        assert!(
8642            p.ampl_options.is_empty(),
8643            "9 declared but 3 present: {:?}",
8644            p.ampl_options
8645        );
8646    }
8647
8648    #[test]
8649    fn constrained_tnlp_eval_g_jac_h() {
8650        let p = parse_nl_text(EQ_LIN).expect("parse");
8651        let mut t = NlTnlp::new(p);
8652        let info = t.get_nlp_info().unwrap();
8653        assert_eq!(info.m, 1);
8654        assert_eq!(info.nnz_jac_g, 2);
8655
8656        // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
8657        let mut g = [0.0_f64; 1];
8658        assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
8659        assert!((g[0] - 0.7).abs() < 1e-12);
8660
8661        // Jacobian structure: row 0, cols [0, 1].
8662        let mut irow = [0_i32; 2];
8663        let mut jcol = [0_i32; 2];
8664        assert!(t.eval_jac_g(
8665            None,
8666            true,
8667            SparsityRequest::Structure {
8668                irow: &mut irow,
8669                jcol: &mut jcol
8670            }
8671        ));
8672        assert_eq!(irow, [0, 0]);
8673        assert_eq!(jcol, [0, 1]);
8674
8675        // Jacobian values: both 1.0.
8676        let mut vals = [0.0_f64; 2];
8677        assert!(t.eval_jac_g(
8678            Some(&[0.3, 0.4]),
8679            true,
8680            SparsityRequest::Values { values: &mut vals }
8681        ));
8682        assert!((vals[0] - 1.0).abs() < 1e-12);
8683        assert!((vals[1] - 1.0).abs() < 1e-12);
8684
8685        // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
8686        // λ contributes nothing because the constraint is linear, and
8687        // x0^2 + x1^2 is separable so there's no (1,0) entry in the
8688        // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
8689        assert_eq!(info.nnz_h_lag, 2);
8690        let mut hirow = [0_i32; 2];
8691        let mut hjcol = [0_i32; 2];
8692        assert!(t.eval_h(
8693            None,
8694            true,
8695            1.0,
8696            None,
8697            true,
8698            SparsityRequest::Structure {
8699                irow: &mut hirow,
8700                jcol: &mut hjcol
8701            }
8702        ));
8703        assert_eq!(hirow, [0, 1]);
8704        assert_eq!(hjcol, [0, 1]);
8705        let mut hvals = [0.0_f64; 2];
8706        assert!(t.eval_h(
8707            Some(&[0.3, 0.4]),
8708            true,
8709            1.0,
8710            Some(&[0.5]),
8711            true,
8712            SparsityRequest::Values { values: &mut hvals }
8713        ));
8714        assert!((hvals[0] - 2.0).abs() < 1e-12);
8715        assert!((hvals[1] - 2.0).abs() < 1e-12);
8716    }
8717
8718    /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
8719    /// encoded as common-subexpression `V2`. Header line 10 declares
8720    /// one obj-only CSE; expression tree references `v2` twice.
8721    const CSE_OBJ: &str = "g3 0 1 0
87222 0 1 0 0
87230 1
87240 0
87250 2 0
87260 0 0 1
87270 0 0 0 0
87280 0
87290 0
87300 1 0 0 0
8731V2 0 0
8732o0
8733v0
8734v1
8735O0 0
8736o0
8737o5
8738v2
8739n2
8740v2
8741b
87423
87433
8744";
8745
8746    #[test]
8747    fn parses_v_segment_cse() {
8748        let p = parse_nl_text(CSE_OBJ).expect("parse");
8749        assert_eq!(p.n, 2);
8750        // f(1,2) = 9 + 3 = 12
8751        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
8752        assert!((f - 12.0).abs() < 1e-12, "got {f}");
8753        // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
8754        let mut g = [0.0_f64; 2];
8755        grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
8756        assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
8757        assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
8758        // collect_vars reaches into the CSE body and finds {0, 1}.
8759        let mut vs = BTreeSet::new();
8760        p.obj_nonlinear.collect_vars(&mut vs);
8761        assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
8762    }
8763
8764    /// `min (x0 - 1)^2` with three suffix segments attached: an
8765    /// integer constraint-suffix (target=1, kind=1), an integer var-
8766    /// suffix (target=0, kind=0), and a real var-suffix (target=0,
8767    /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
8768    /// `<idx> <value>` lines.
8769    const WITH_SUFFIXES: &str = "g3 0 1 0
87701 0 1 0 0
87710 1
87720 0
87730 1 0
87740 0 0 1
87750 0 0 0 0
87760 0
87770 0
87780 0 0 0 0
8779O0 0
8780o5
8781o1
8782v0
8783n1
8784n2
8785b
87863
8787S0 1 sens_state_1
87880 7
8789S4 1 sens_state_value_1
87900 4.5
8791";
8792
8793    #[test]
8794    fn parses_var_int_and_var_real_suffixes() {
8795        let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
8796        // Integer var-suffix: dense length 1, slot 0 = 7.
8797        let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
8798        assert_eq!(v.as_slice(), &[7]);
8799        // Real var-suffix: dense length 1, slot 0 = 4.5.
8800        let r = p
8801            .suffixes
8802            .var_real
8803            .get("sens_state_value_1")
8804            .expect("var_real");
8805        assert_eq!(r.len(), 1);
8806        assert!((r[0] - 4.5).abs() < 1e-12);
8807        // Other suffix slots stay empty.
8808        assert!(p.suffixes.con_int.is_empty());
8809        assert!(p.suffixes.con_real.is_empty());
8810    }
8811
8812    /// Two-variable + two-constraint problem with a constraint-level
8813    /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
8814    const WITH_CON_SUFFIX: &str = "g3 0 1 0
88152 2 1 0 0
88160 0
88170 0
88180 2 0
88190 0 0 1
88200 0 0 0 0
88214 0
88220 0
88230 0 0 0 0 0
8824C0
8825n0
8826C1
8827n0
8828O0 0
8829n0
8830r
88314 0.0
88324 0.0
8833b
88343
88353
8836k1
88370
8838J0 2
88390 1
88401 1
8841J1 2
88420 1
88431 -1
8844S1 2 sens_init_constr
88450 1
88461 2
8847";
8848
8849    #[test]
8850    fn parses_con_int_suffix() {
8851        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
8852        let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
8853        // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
8854        assert_eq!(s.as_slice(), &[1, 2]);
8855    }
8856
8857    // ---- gh#785: a truncated file is rejected, not silently defaulted ----
8858    //
8859    // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
8860    // segment boundary is exactly the failure the issue reports: an
8861    // interrupted write. Each cut loses a different first segment and each
8862    // is caught by a different check, so all three are asserted — and the
8863    // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
8864    // what keeps these from passing against a parser that rejects
8865    // everything.
8866
8867    /// Everything from `at` (a segment header line) onward is gone.
8868    fn truncate_before(txt: &str, at: &str) -> String {
8869        let cut = txt
8870            .find(at)
8871            .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
8872        txt[..cut].to_string()
8873    }
8874
8875    #[test]
8876    fn truncation_before_the_row_bounds_is_a_parse_error() {
8877        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
8878            .expect_err("truncated file must not parse");
8879        assert!(
8880            err.contains("`r` (constraint-bounds) segment"),
8881            "error should name the missing segment: {err}"
8882        );
8883    }
8884
8885    #[test]
8886    fn truncation_before_the_variable_bounds_is_a_parse_error() {
8887        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
8888            .expect_err("truncated file must not parse");
8889        assert!(
8890            err.contains("`b` (variable-bounds) segment"),
8891            "error should name the missing segment: {err}"
8892        );
8893    }
8894
8895    /// The cut that leaves every bound in place and takes only the
8896    /// coefficients. Neither presence check can see it; the declared-vs-
8897    /// parsed nonzero count is the whole of the evidence.
8898    #[test]
8899    fn truncation_before_the_jacobian_is_a_parse_error() {
8900        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
8901            .expect_err("truncated file must not parse");
8902        assert!(
8903            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
8904            "error should report the mismatch: {err}"
8905        );
8906    }
8907
8908    /// The mismatch is an equality, not a floor: a file supplying *more*
8909    /// entries than it declares is as corrupt as one supplying fewer, and a
8910    /// `>=` check would wave it through.
8911    #[test]
8912    fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
8913        let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
8914        let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
8915        assert!(
8916            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
8917            "error should report the mismatch: {err}"
8918        );
8919    }
8920
8921    /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
8922    /// (n = m = 2) and hand back everything the engine would see.
8923    fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
8924        let mut obj = 1.0;
8925        let mut use_x = false;
8926        let mut x = vec![0.0; 2];
8927        let mut use_g = false;
8928        let mut g = vec![0.0; 2];
8929        let ok = tnlp.get_scaling_parameters(ScalingRequest {
8930            obj_scaling: &mut obj,
8931            use_x_scaling: &mut use_x,
8932            x_scaling: &mut x,
8933            use_g_scaling: &mut use_g,
8934            g_scaling: &mut g,
8935        });
8936        (ok, obj, use_x, x, use_g, g)
8937    }
8938
8939    /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
8940    /// the objective (`S6`) and one constraint (`S5`) reaches the
8941    /// engine's `user-scaling` pathway. The untagged second row is
8942    /// unscaled — its AMPL suffix default is 0, which is not a usable
8943    /// scale factor and reads as "not tagged".
8944    #[test]
8945    fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
8946        let nl = WITH_CON_SUFFIX.to_string()
8947            + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
8948        let p = parse_nl_text(&nl).expect("parse");
8949        let mut tnlp = NlTnlp::new(p);
8950        let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
8951        assert!(ok, "a tagged model must supply scaling");
8952        assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
8953        assert!(use_g);
8954        assert_eq!(g, vec![10.0, 1.0]);
8955        assert!(!use_x, "no variable suffix was declared");
8956    }
8957
8958    /// Variable-level `scaling_factor` entries are passed through, not
8959    /// dropped on the floor: `OrigIpoptNlp` does not model them and
8960    /// refuses the solve, which is the whole point of gh#483.
8961    #[test]
8962    fn scaling_factor_suffix_forwards_variable_factors() {
8963        let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
8964        let p = parse_nl_text(&nl).expect("parse");
8965        let mut tnlp = NlTnlp::new(p);
8966        let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
8967        assert!(ok);
8968        assert!(use_x, "variable factors must reach the engine");
8969        assert_eq!(x, vec![1.0, 3.0]);
8970    }
8971
8972    /// gh #703: with curvature-based scaling switched on, the computed
8973    /// factors are the base and a `scaling_factor` suffix the model
8974    /// actually carries wins **component by component** — an explicit
8975    /// factor from the modeller beats one inferred from the coefficients,
8976    /// and the components they did not tag keep the inferred one rather
8977    /// than snapping back to 1.
8978    #[test]
8979    fn a_user_suffix_overrides_the_computed_factor_component_wise() {
8980        let baseline = {
8981            let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
8982            let mut t = NlTnlp::new(p);
8983            assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
8984            scaling_of(&mut t).5
8985        };
8986        assert!(
8987            baseline.iter().all(|v| *v > 0.0),
8988            "curvature scaling should produce usable row factors, got {baseline:?}"
8989        );
8990
8991        // Tag row 0 only. AMPL's untagged default is 0, which must read as
8992        // "not tagged" and leave row 1 on the computed factor.
8993        let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
8994        let p = parse_nl_text(&nl).expect("parse");
8995        let mut t = NlTnlp::new(p);
8996        assert!(t.enable_curvature_scaling());
8997        let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
8998        assert!(ok);
8999        assert!(use_g && use_x);
9000        assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
9001        assert_eq!(
9002            g[1], baseline[1],
9003            "the untagged row keeps the computed one, not 1.0"
9004        );
9005    }
9006
9007    /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
9008    /// same answer the default `TNLP` impl gives, so `user-scaling`
9009    /// falls back to no scaling instead of a bogus all-zero vector.
9010    #[test]
9011    fn no_scaling_factor_suffix_declines() {
9012        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9013        let mut tnlp = NlTnlp::new(p);
9014        let (ok, ..) = scaling_of(&mut tnlp);
9015        assert!(!ok);
9016    }
9017
9018    #[test]
9019    fn rejects_suffix_with_out_of_range_index() {
9020        let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
9021        let err = parse_nl_text(&bad).expect_err("must reject");
9022        assert!(
9023            err.contains("out of range"),
9024            "expected out-of-range error, got: {err}"
9025        );
9026    }
9027
9028    #[test]
9029    fn tnlp_round_trip_solves() {
9030        let p = parse_nl_text(SIMPLE).expect("parse");
9031        let mut tnlp = NlTnlp::new(p);
9032        let info = tnlp.get_nlp_info().unwrap();
9033        assert_eq!(info.n, 2);
9034        assert_eq!(info.m, 0);
9035        let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
9036        assert!((f0 - 5.0).abs() < 1e-12);
9037        let mut g = [0.0_f64; 2];
9038        tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
9039        // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
9040        assert!((g[0] - (-2.0)).abs() < 1e-12);
9041        assert!((g[1] - (-4.0)).abs() < 1e-12);
9042    }
9043
9044    // ---- Sibling `.col` / `.row` name-file capture --------------------
9045    //
9046    // Names let diagnostics name the offending equation instead of "row 3"
9047    // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
9048    // the read path and the documented fallback-to-empty behavior.
9049
9050    use pounce_nlp::expression_provider::ExpressionProvider;
9051    use std::sync::atomic::{AtomicUsize, Ordering};
9052
9053    /// Unique scratch dir for one test (no `tempfile` dev-dep available).
9054    fn scratch_dir(tag: &str) -> std::path::PathBuf {
9055        static N: AtomicUsize = AtomicUsize::new(0);
9056        let seq = N.fetch_add(1, Ordering::Relaxed);
9057        let dir = std::env::temp_dir().join(format!(
9058            "pounce_nlnames_{}_{}_{}",
9059            std::process::id(),
9060            tag,
9061            seq
9062        ));
9063        std::fs::create_dir_all(&dir).expect("create scratch dir");
9064        dir
9065    }
9066
9067    #[test]
9068    fn read_name_file_reads_in_order() {
9069        let dir = scratch_dir("col_order");
9070        let p = dir.join("m.col");
9071        std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
9072        assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
9073    }
9074
9075    #[test]
9076    fn read_name_file_truncates_extra_lines() {
9077        // `.row` conventionally appends the objective name after the m
9078        // constraint names; `.take(expected)` must drop it so names stay
9079        // 1:1 with `g`.
9080        let dir = scratch_dir("row_obj");
9081        let p = dir.join("m.row");
9082        std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
9083        assert_eq!(
9084            read_name_file(&p, 2),
9085            vec!["mass_balance", "energy_balance"]
9086        );
9087    }
9088
9089    #[test]
9090    fn read_name_file_empty_on_short_or_missing() {
9091        let dir = scratch_dir("short");
9092        let short = dir.join("m.col");
9093        std::fs::write(&short, "only_one\n").unwrap();
9094        // Fewer lines than expected ⇒ empty (never a partial mapping).
9095        assert!(read_name_file(&short, 3).is_empty());
9096        // Missing file ⇒ empty, no error.
9097        assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
9098    }
9099
9100    #[test]
9101    fn read_nl_file_captures_sibling_names() {
9102        // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
9103        // names ride through onto the TNLP's ExpressionProvider.
9104        let dir = scratch_dir("sibling");
9105        let nl = dir.join("m.nl");
9106        std::fs::write(&nl, SIMPLE).unwrap();
9107        std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
9108
9109        let prob = read_nl_file(&nl).expect("parse + name capture");
9110        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9111        assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
9112
9113        let tnlp = NlTnlp::new(prob);
9114        assert_eq!(tnlp.variable_name(0), Some("alpha"));
9115        assert_eq!(tnlp.variable_name(1), Some("beta"));
9116        assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
9117    }
9118
9119    #[test]
9120    fn read_nl_file_without_names_yields_empty() {
9121        let dir = scratch_dir("noname");
9122        let nl = dir.join("m.nl");
9123        std::fs::write(&nl, SIMPLE).unwrap();
9124        let prob = read_nl_file(&nl).expect("parse");
9125        assert!(prob.var_names.is_empty());
9126        assert!(prob.con_names.is_empty());
9127        let tnlp = NlTnlp::new(prob);
9128        assert_eq!(tnlp.variable_name(0), None);
9129    }
9130
9131    #[test]
9132    fn read_nl_file_resolves_extensionless_ampl_stub() {
9133        // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
9134        // the `.nl` extension; the solver must read `mystub.nl`. Code
9135        // review 2026-06 item M15.
9136        let dir = scratch_dir("stub");
9137        std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
9138        // Pass the extensionless stub — the file `mystub` does not exist.
9139        let stub = dir.join("mystub");
9140        assert!(!stub.exists(), "stub must be extensionless / absent");
9141        let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
9142        assert_eq!(prob.n, 2);
9143        assert_eq!(prob.m, 0);
9144
9145        // Sibling name files are still found off the resolved stem.
9146        std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
9147        let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
9148        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9149    }
9150
9151    #[test]
9152    fn read_nl_file_prefers_exact_path_over_nl_sibling() {
9153        // An existing path is read verbatim — the `.nl` fallback only
9154        // kicks in when the literal path is missing, so a caller passing a
9155        // real file is never silently redirected to a `<file>.nl` sibling.
9156        let dir = scratch_dir("exact");
9157        // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
9158        std::fs::write(dir.join("data"), SIMPLE).unwrap();
9159        std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
9160        let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
9161        assert_eq!(prob.n, 2);
9162    }
9163
9164    #[test]
9165    fn append_extension_appends_rather_than_replaces() {
9166        use std::path::Path;
9167        assert_eq!(
9168            append_extension(Path::new("mystub"), "nl"),
9169            Path::new("mystub.nl")
9170        );
9171        // A stub that itself contains a dot keeps its stem (AMPL names it
9172        // `my.model.nl`, not `my.nl`).
9173        assert_eq!(
9174            append_extension(Path::new("my.model"), "nl"),
9175            Path::new("my.model.nl")
9176        );
9177    }
9178
9179    // ---- equation rendering (`print equation`) ----
9180
9181    fn names(v: &[&str]) -> Vec<String> {
9182        v.iter().map(|s| s.to_string()).collect()
9183    }
9184
9185    #[test]
9186    fn render_uses_variable_names_when_present() {
9187        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9188        assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
9189        // Falls back to x[i] when names are absent.
9190        assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
9191    }
9192
9193    #[test]
9194    fn render_parenthesizes_by_precedence() {
9195        // (x0 + x1) * x2  must keep the parens around the sum.
9196        let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9197        let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
9198        assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
9199
9200        // x0 + x1 * x2  needs no parens (mul binds tighter).
9201        let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9202        let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
9203        assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
9204    }
9205
9206    #[test]
9207    fn render_subtraction_right_assoc_parens() {
9208        // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
9209        let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9210        let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
9211        assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
9212    }
9213
9214    #[test]
9215    fn render_functions_and_pow() {
9216        let sq = Expr::Binary(
9217            BinOp::Pow,
9218            Box::new(Expr::Var(0)),
9219            Box::new(Expr::Const(2.0)),
9220        );
9221        let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
9222        assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
9223    }
9224
9225    #[test]
9226    fn render_linear_signs_are_tidy() {
9227        // 1*a - 2*b + c  (coef +1 omits the multiplier).
9228        let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
9229        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
9230    }
9231
9232    #[test]
9233    fn render_linear_skips_zero_coefficients() {
9234        // A 0 coefficient (a variable present only in the nonlinear part)
9235        // is dropped, not rendered as `0*x`.
9236        let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
9237        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
9238        // Leading term zero ⇒ the first emitted term still has no ` + `.
9239        let lin = vec![(0usize, 0.0), (1, 2.0)];
9240        assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
9241    }
9242
9243    #[test]
9244    fn render_sum_folds_negative_terms() {
9245        // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
9246        let sq = |i| {
9247            Expr::Binary(
9248                BinOp::Pow,
9249                Box::new(Expr::Var(i)),
9250                Box::new(Expr::Const(2.0)),
9251            )
9252        };
9253        let neg = |i| {
9254            Expr::Binary(
9255                BinOp::Mul,
9256                Box::new(Expr::Const(-1.0)),
9257                Box::new(Expr::Var(i)),
9258            )
9259        };
9260        let e = Expr::Sum(vec![
9261            sq(0),
9262            neg(1),
9263            Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
9264        ]);
9265        assert_eq!(
9266            render_expr(&e, &names(&["a", "b", "c"]), &[]),
9267            "a^2 - 1*b - c"
9268        );
9269    }
9270
9271    #[test]
9272    fn render_constraint_equation_forms() {
9273        // Build a 2-constraint problem by hand: an equality and a range.
9274        let mut prob = parse_nl_text(SIMPLE).unwrap();
9275        // Overwrite to a known small shape: 1 var, 2 cons.
9276        prob.n = 2;
9277        prob.m = 2;
9278        prob.var_names = names(&["mass_in", "mass_out"]);
9279        prob.con_names = names(&["balance", "window"]);
9280        prob.con_linear = vec![
9281            vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
9282            vec![(0, 1.0)],            // mass_in
9283        ];
9284        prob.con_nonlinear = vec![
9285            NlBody::Tree(Expr::Const(0.0)),
9286            NlBody::Tree(Expr::Const(0.0)),
9287        ];
9288        prob.g_l = vec![0.0, 0.0];
9289        prob.g_u = vec![0.0, 500.0];
9290
9291        assert_eq!(
9292            render_constraint_equation(&prob, 0),
9293            "mass_in - mass_out = 0"
9294        );
9295        assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
9296
9297        let all = render_all_constraint_equations(&prob);
9298        assert_eq!(all.len(), 2);
9299        assert_eq!(all[1], "0 <= mass_in <= 500");
9300    }
9301
9302    #[test]
9303    fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
9304        let mut prob = parse_nl_text(SIMPLE).unwrap();
9305        prob.n = 3;
9306        prob.m = 2;
9307        // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
9308        // Row 1: linear in x2 only → support {2}.
9309        prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
9310        prob.con_nonlinear = vec![
9311            NlBody::Tree(Expr::Binary(
9312                BinOp::Mul,
9313                Box::new(Expr::Var(0)),
9314                Box::new(Expr::Var(2)),
9315            )),
9316            NlBody::Tree(Expr::Const(0.0)),
9317        ];
9318        prob.g_l = vec![0.0, 0.0];
9319        prob.g_u = vec![0.0, 0.0];
9320
9321        let (irow, jcol) = constraint_jacobian_sparsity(&prob);
9322        // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
9323        assert_eq!(irow, vec![0, 0, 0, 1]);
9324        assert_eq!(jcol, vec![0, 1, 2, 2]);
9325    }
9326
9327    #[test]
9328    fn funcall_string_arg_with_hash_is_not_truncated() {
9329        // Code review L31: an AMPL string argument is a Hollerith literal
9330        // `h<len>:<chars>` whose content is exactly <len> bytes and may
9331        // legitimately contain '#' (e.g. a parameters-directory path). The
9332        // old parser ran strip_comment() over the line first, truncating
9333        // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
9334        let mut p = Parser::new("h3:a#b\n", false);
9335        match p.parse_funcall_arg().expect("parse hollerith arg") {
9336            FuncallArg::Str(s) => assert_eq!(s, "a#b"),
9337            other => panic!("expected Str, got {other:?}"),
9338        }
9339    }
9340
9341    #[test]
9342    fn funcall_string_arg_honors_declared_length() {
9343        // The declared `<len>` is authoritative: exactly that many bytes
9344        // after the ':' form the string; trailing content (here a real
9345        // ` # comment`) is not part of it.
9346        let mut p = Parser::new("h3:abc # trailing comment\n", false);
9347        match p.parse_funcall_arg().expect("parse hollerith arg") {
9348            FuncallArg::Str(s) => assert_eq!(s, "abc"),
9349            other => panic!("expected Str, got {other:?}"),
9350        }
9351    }
9352
9353    // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
9354    //
9355    // AMPL emits these in place of the general `o5` (OPPOW) when one operand
9356    // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
9357    // negative-base-safe constant-power lowering applies. The eval points below
9358    // are chosen to pin down BOTH the arity and the operand order: a swapped
9359    // `base`/`exp` (or treating `o82` as a different unary op) gives a
9360    // different number at these points, so each assertion is discriminating.
9361
9362    /// Parse a single expression `expr_src` with `n` variables in scope,
9363    /// driving the real `parse_opcode` path through `parse_expr`.
9364    fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
9365        let mut p = Parser::new(expr_src, false);
9366        p.n = n;
9367        p.parse_expr().expect("parse expression")
9368    }
9369
9370    #[test]
9371    fn opcode_o82_square_is_unary_pow_of_two() {
9372        // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
9373        let e = parse_one_expr(1, "o82\nv0\n");
9374        match &e {
9375            Expr::Binary(BinOp::Pow, base, exp) => {
9376                assert!(matches!(**base, Expr::Var(0)));
9377                match **exp {
9378                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
9379                    ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
9380                }
9381            }
9382            other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
9383        }
9384        // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
9385        // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
9386        assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
9387        assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
9388        // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
9389        let mut g = [0.0_f64; 1];
9390        grad_expr(&e, &[3.0], 1.0, &mut g);
9391        assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
9392        g[0] = 0.0;
9393        grad_expr(&e, &[-3.0], 1.0, &mut g);
9394        assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
9395    }
9396
9397    #[test]
9398    fn opcode_o81_const_exponent_is_base_pow_const() {
9399        // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
9400        let e = parse_one_expr(1, "o81\nv0\nn3\n");
9401        match &e {
9402            Expr::Binary(BinOp::Pow, base, exp) => {
9403                assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
9404                match **exp {
9405                    Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
9406                    ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
9407                }
9408            }
9409            other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
9410        }
9411        // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
9412        assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
9413        // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
9414        // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
9415        assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
9416        // gradient d/dx x^3 = 3x^2 = 12 at x=2.
9417        let mut g = [0.0_f64; 1];
9418        grad_expr(&e, &[2.0], 1.0, &mut g);
9419        assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
9420    }
9421
9422    #[test]
9423    fn opcode_o83_const_base_is_const_pow_exp() {
9424        // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
9425        let e = parse_one_expr(1, "o83\nn2\nv0\n");
9426        match &e {
9427            Expr::Binary(BinOp::Pow, base, exp) => {
9428                match **base {
9429                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
9430                    ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
9431                }
9432                assert!(
9433                    matches!(**exp, Expr::Var(0)),
9434                    "exponent must be the variable"
9435                );
9436            }
9437            other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
9438        }
9439        // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
9440        assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
9441        assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
9442        // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
9443        let mut g = [0.0_f64; 1];
9444        grad_expr(&e, &[3.0], 1.0, &mut g);
9445        assert!(
9446            (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
9447            "grad at 3 = {} (want {})",
9448            g[0],
9449            8.0 * 2.0_f64.ln()
9450        );
9451    }
9452
9453    #[test]
9454    fn power_specializations_agree_with_general_o5() {
9455        // Where both are defined, o81/o82/o83 must be numerically identical to
9456        // the general `o5` pow on the same operands — they are only routing
9457        // hints, not different math.
9458        let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
9459        let o82 = parse_one_expr(1, "o82\nv0\n");
9460        let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
9461        let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
9462        let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
9463        let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
9464        for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
9465            assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
9466            assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
9467            // 2^x is real for all x; compare across the same points.
9468            assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
9469        }
9470    }
9471
9472    #[test]
9473    fn power_opcodes_round_trip_through_parse_nl_text() {
9474        // End-to-end through the public entry point: `min x0^2 + x1^2` written
9475        // with o82 (square) parses and evaluates like its o5 twin. Reuses the
9476        // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
9477        let nl = SIMPLE.replace(
9478            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9479            "o0\no82\nv0\no82\nv1\n",
9480        );
9481        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9482        let p = parse_nl_text(&nl).expect("parse o82 objective");
9483        // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
9484        assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
9485        assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
9486    }
9487
9488    #[test]
9489    fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
9490        // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
9491        // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
9492        // (the negative-base-safe path) rather than a generic `powf`. The check
9493        // at a NEGATIVE base is the one that would break if o81 wrongly routed
9494        // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
9495        let nl = SIMPLE.replace(
9496            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9497            "o0\no81\nv0\nn3\no81\nv1\nn3\n",
9498        );
9499        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9500        let p = parse_nl_text(&nl).expect("parse o81 objective");
9501        let mut tnlp = NlTnlp::new(p);
9502        tnlp.get_nlp_info().unwrap();
9503        // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
9504        let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
9505        assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
9506        // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
9507        let mut g = [0.0_f64; 2];
9508        assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
9509        assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
9510        assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
9511    }
9512
9513    // ---- Shared-CSE constraint tape (issue #476) ----------------------
9514
9515    /// Three constraints over one `V` segment (a `.nl` *defined variable*),
9516    /// `V3 = 2*x0 + 3*x1`, referenced by all three:
9517    ///   C0: V3^2        C1: V3^3 + x2        C2: V3*x2
9518    /// `{BODY2}` is a substitution point so a variant can drop an opcode the
9519    /// hybrid path rejects into C2.
9520    const SHARED_CSE: &str = "g3 1 1 0
9521 3 3 1 0 0
9522 3 0
9523 0 0
9524 3 0 0
9525 0 0 0 1
9526 0 0 0 0 0
9527 8 3
9528 0 0
9529 0 1 0 0 0
9530V3 2 0
95310 2.0
95321 3.0
9533n0
9534C0
9535o5
9536v3
9537n2
9538C1
9539o0
9540o5
9541v3
9542n3
9543v2
9544C2
9545{BODY2}
9546O0 0
9547n0
9548r
95492 0
95502 0
95512 0
9552b
95533
95543
95553
9556k2
95573
95586
9559J0 2
95600 0
95611 0
9562J1 3
95630 0
95641 0
95652 0
9566J2 3
95670 0
95681 0
95692 0
9570G0 3
95710 1.0
95721 1.0
95732 1.0
9574";
9575
9576    fn shared_cse_nl(body2: &str) -> String {
9577        SHARED_CSE.replace("{BODY2}", body2)
9578    }
9579
9580    /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
9581    /// per-summand tapes return — it is a different traversal of the same
9582    /// DAG, not a different derivative. Forced on here regardless of
9583    /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
9584    /// the size heuristic that decides when to use it.
9585    #[test]
9586    fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
9587        let nl = shared_cse_nl("o2\nv3\nv2");
9588        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9589
9590        let mut hybrid = NlTnlp::new(p.clone());
9591        let info = hybrid.get_nlp_info().unwrap();
9592        let nnz = info.nnz_jac_g as usize;
9593        hybrid
9594            .con_hybrid
9595            .as_mut()
9596            .expect("CSE shared by 3 constraints must build the hybrid tape")
9597            .use_for_jac = true;
9598
9599        let mut flat = NlTnlp::new(p);
9600        flat.get_nlp_info().unwrap();
9601        flat.con_hybrid = None;
9602
9603        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9604            let mut jh = vec![0.0_f64; nnz];
9605            let mut jf = vec![0.0_f64; nnz];
9606            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9607            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9608            assert_eq!(
9609                jh, jf,
9610                "hybrid Jacobian differs from the flat tape at {x:?}"
9611            );
9612
9613            // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
9614            let s = 2.0 * x[0] + 3.0 * x[1];
9615            let want = [
9616                4.0 * s,
9617                6.0 * s,
9618                6.0 * s * s,
9619                9.0 * s * s,
9620                1.0,
9621                2.0 * x[2],
9622                3.0 * x[2],
9623                s,
9624            ];
9625            assert_eq!(nnz, want.len());
9626            for k in 0..nnz {
9627                assert!(
9628                    (jh[k] - want[k]).abs() < 1e-9,
9629                    "entry {k} at {x:?}: got {}, want {}",
9630                    jh[k],
9631                    want[k]
9632                );
9633            }
9634        }
9635    }
9636
9637    /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
9638    /// CSE `S = body(x0, x1)` (the caller passes the body's expression
9639    /// text, which must reference exactly `v0` and `v1`). A deep shared
9640    /// body over few local ops per row is the regime the op-ratio gates
9641    /// are meant to catch: no repository fixture has a CSE shared across
9642    /// constraints at all, so without this the gate-on paths have no
9643    /// natural coverage.
9644    fn shared_body_chain_nl(m: usize, body: &str) -> String {
9645        let n = m + 2;
9646        let nzc = 3 * m;
9647        let mut s = String::new();
9648        s.push_str("g3 1 1 0\n");
9649        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9650        s.push_str(&format!(" {m} 0\n 0 0\n"));
9651        s.push_str(&format!(" {n} 0 0\n"));
9652        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9653        s.push_str(&format!(" {nzc} {n}\n"));
9654        s.push_str(" 0 0\n 0 1 0 0 0\n");
9655        // The shared body.
9656        s.push_str(&format!("V{n} 0 0\n"));
9657        s.push_str(body);
9658        // Rows: S * x_{i+2}.
9659        for i in 0..m {
9660            s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
9661        }
9662        s.push_str("O0 0\nn0\n");
9663        s.push_str(&format!("x{n}\n"));
9664        for j in 0..n {
9665            s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
9666        }
9667        s.push_str("r\n");
9668        for _ in 0..m {
9669            s.push_str("2 0\n");
9670        }
9671        s.push_str("b\n");
9672        for _ in 0..n {
9673            s.push_str("3\n");
9674        }
9675        // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
9676        s.push_str(&format!("k{}\n", n - 1));
9677        let mut acc = 0;
9678        for j in 0..n - 1 {
9679            acc += if j < 2 { m } else { 1 };
9680            s.push_str(&format!("{acc}\n"));
9681        }
9682        for i in 0..m {
9683            s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
9684        }
9685        s.push_str(&format!("G0 {n}\n"));
9686        for j in 0..n {
9687            s.push_str(&format!("{j} 0.0\n"));
9688        }
9689        s
9690    }
9691
9692    /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
9693    /// The forward value saturates to `inf` past depth ≈ 5, which the
9694    /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
9695    /// bounded variant below instead, whose second-order terms would
9696    /// otherwise mix `inf`s of both signs into `NaN`.
9697    fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
9698        let mut body = String::new();
9699        for _ in 0..depth {
9700            body.push_str("o44\n");
9701        }
9702        body.push_str("o2\nn0.01\no0\nv0\nv1\n");
9703        shared_body_chain_nl(m, &body)
9704    }
9705
9706    /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
9707    /// (x0 + x1))` — mathematically the identity chain, so the value
9708    /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
9709    /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
9710    /// through both second-order sweeps. `2 * pairs` body ops drive the
9711    /// flat/shared op ratio as high as the Hessian gate tests need.
9712    fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
9713        let mut body = String::new();
9714        for _ in 0..pairs {
9715            body.push_str("o43\no44\n");
9716        }
9717        body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
9718        shared_body_chain_nl(m, &body)
9719    }
9720
9721    /// With a deep shared body the gate turns itself on, and the path it
9722    /// turns on must still agree with the flat tapes exactly.
9723    #[test]
9724    fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
9725        let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
9726
9727        let mut hybrid = NlTnlp::new(p.clone());
9728        let info = hybrid.get_nlp_info().unwrap();
9729        let nnz = info.nnz_jac_g as usize;
9730        assert!(
9731            hybrid
9732                .con_hybrid
9733                .as_ref()
9734                .expect("shared CSE must build the hybrid tape")
9735                .use_for_jac,
9736            "a 40-deep body shared by 16 rows is well past the op-ratio gate"
9737        );
9738
9739        let mut flat = NlTnlp::new(p);
9740        flat.get_nlp_info().unwrap();
9741        flat.con_hybrid = None;
9742
9743        for scale in [1.0_f64, -0.7, 2.5] {
9744            let x: Vec<f64> = (0..info.n as usize)
9745                .map(|j| scale * (0.2 + 0.03 * j as f64))
9746                .collect();
9747            let mut jh = vec![0.0_f64; nnz];
9748            let mut jf = vec![0.0_f64; nnz];
9749            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9750            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9751            assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
9752            assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
9753        }
9754    }
9755
9756    /// The Jacobian gate is off for a model whose shared bodies are small,
9757    /// because there the hybrid traversal's per-op overhead outweighs the
9758    /// halved forward sweep. `eval_g` still takes the hybrid path — that
9759    /// one is a win at any ratio.
9760    #[test]
9761    fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
9762        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
9763        let mut t = NlTnlp::new(p);
9764        t.get_nlp_info().unwrap();
9765        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
9766        assert!(
9767            !h.use_for_jac,
9768            "a 3-row model with a 2-term CSE is far below the op-ratio gate"
9769        );
9770    }
9771
9772    /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
9773    /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
9774    /// adjoints and running one prelude sweep reassociates floating-point
9775    /// products, so the two paths agree only to rounding on general inputs —
9776    /// but here every operation in both traversals is exact (dyadic values,
9777    /// small-integer coefficients, polynomial ops), so the results must be
9778    /// bit-identical, pinning the arithmetic itself and not just its
9779    /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
9780    /// path is covered independently of the size heuristic.
9781    #[test]
9782    fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
9783        let nl = shared_cse_nl("o2\nv3\nv2");
9784        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9785
9786        let mut hybrid = NlTnlp::new(p.clone());
9787        let info = hybrid.get_nlp_info().unwrap();
9788        let nnz = info.nnz_h_lag as usize;
9789        hybrid
9790            .con_hybrid
9791            .as_mut()
9792            .expect("CSE shared by 3 constraints must build the hybrid tape")
9793            .use_for_hess = true;
9794
9795        let mut flat = NlTnlp::new(p);
9796        flat.get_nlp_info().unwrap();
9797        flat.con_hybrid = None;
9798
9799        let pairs: Vec<(usize, usize)> = hybrid
9800            .h_irow
9801            .iter()
9802            .zip(&hybrid.h_jcol)
9803            .map(|(&i, &j)| (i as usize, j as usize))
9804            .collect();
9805
9806        // Two multiplier sets: one all-live, one with a dead row so the
9807        // λ == 0 skip is exercised on the hybrid path too.
9808        for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
9809            for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9810                let mut hh = vec![0.0_f64; nnz];
9811                let mut hf = vec![0.0_f64; nnz];
9812                assert!(hybrid.eval_h(
9813                    Some(&x),
9814                    true,
9815                    1.0,
9816                    Some(&lam),
9817                    true,
9818                    SparsityRequest::Values { values: &mut hh }
9819                ));
9820                assert!(flat.eval_h(
9821                    Some(&x),
9822                    true,
9823                    1.0,
9824                    Some(&lam),
9825                    true,
9826                    SparsityRequest::Values { values: &mut hf }
9827                ));
9828                assert_eq!(
9829                    hh, hf,
9830                    "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
9831                );
9832
9833                // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
9834                // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
9835                // objective is constant, so the Lagrangian Hessian is
9836                //   (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
9837                let s = 2.0 * x[0] + 3.0 * x[1];
9838                let q = 2.0 * lam[0] + 6.0 * s * lam[1];
9839                let dv = [2.0, 3.0, 0.0];
9840                for (k, &(i, j)) in pairs.iter().enumerate() {
9841                    let mut want = q * dv[i] * dv[j];
9842                    if i == 2 {
9843                        want += lam[2] * dv[j];
9844                    }
9845                    if j == 2 {
9846                        want += lam[2] * dv[i];
9847                    }
9848                    assert!(
9849                        (hh[k] - want).abs() < 1e-9,
9850                        "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
9851                        hh[k]
9852                    );
9853                }
9854            }
9855        }
9856    }
9857
9858    /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
9859    /// turns the Hessian gate on by itself, and the path it turns on must
9860    /// agree with the flat tapes. Not bitwise here: the shared prelude
9861    /// reverse sweep runs once over the `λ_k`-folded adjoints of all
9862    /// summands where the flat path runs per summand, and that
9863    /// reassociation moves transcendental results by rounding — so the bar
9864    /// is a relative few-ULP band, with the exact-arithmetic case pinned
9865    /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
9866    #[test]
9867    fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
9868        let m = 16;
9869        let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
9870
9871        let mut hybrid = NlTnlp::new(p.clone());
9872        let info = hybrid.get_nlp_info().unwrap();
9873        let nnz = info.nnz_h_lag as usize;
9874        assert!(
9875            hybrid
9876                .con_hybrid
9877                .as_ref()
9878                .expect("shared CSE must build the hybrid tape")
9879                .use_for_hess,
9880            "a 40-op body shared by 16 rows is well past the op-ratio gate"
9881        );
9882
9883        let mut flat = NlTnlp::new(p);
9884        flat.get_nlp_info().unwrap();
9885        flat.con_hybrid = None;
9886
9887        let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
9888        for scale in [1.0_f64, -0.7, 2.5] {
9889            let x: Vec<f64> = (0..info.n as usize)
9890                .map(|j| scale * (0.2 + 0.03 * j as f64))
9891                .collect();
9892            // Run the hybrid Jacobian first, the order a real solve
9893            // iteration uses. Its `gradient_summand` sweeps leave the
9894            // *Jacobian's* prelude adjoint arena dirty; the Hessian's
9895            // accumulators must be its own buffers with the all-zero-
9896            // between-colors invariant, or this seeds `eval_h` with a
9897            // stale row gradient (caught here).
9898            let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
9899            assert!(hybrid.eval_jac_g(
9900                Some(&x),
9901                true,
9902                SparsityRequest::Values { values: &mut jac }
9903            ));
9904            let mut hh = vec![0.0_f64; nnz];
9905            let mut hf = vec![0.0_f64; nnz];
9906            assert!(hybrid.eval_h(
9907                Some(&x),
9908                true,
9909                1.0,
9910                Some(&lam),
9911                true,
9912                SparsityRequest::Values { values: &mut hh }
9913            ));
9914            assert!(flat.eval_h(
9915                Some(&x),
9916                true,
9917                1.0,
9918                Some(&lam),
9919                true,
9920                SparsityRequest::Values { values: &mut hf }
9921            ));
9922            for k in 0..nnz {
9923                assert!(
9924                    hh[k].is_finite() && hf[k].is_finite(),
9925                    "non-finite Hessian entry {k} defeats the comparison"
9926                );
9927                let tol = 1e-12 * hf[k].abs().max(1.0);
9928                assert!(
9929                    (hh[k] - hf[k]).abs() <= tol,
9930                    "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
9931                    hh[k],
9932                    hf[k]
9933                );
9934            }
9935            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
9936        }
9937    }
9938
9939    /// `.nl` text for two independent shared-CSE blocks of different widths:
9940    /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
9941    /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
9942    /// of the form `body * x_r`.
9943    ///
9944    /// The width difference is the point. A body summing `w` variables gives
9945    /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
9946    /// conflict and the coloring must spend `w` colors on them; the narrower
9947    /// block reuses the low colors. The surplus colors therefore belong to
9948    /// block A alone, and a per-color prelude walk over *both* bodies would
9949    /// be doing work for a body that color cannot reach.
9950    fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
9951        let nvars = wide + narrow;
9952        let m = 2 * rows;
9953        let n = nvars + m;
9954        // Block A rows touch `wide` body vars + 1 row var; block B rows
9955        // touch `narrow` + 1.
9956        let nzc = rows * (wide + 1) + rows * (narrow + 1);
9957        let mut s = String::new();
9958        s.push_str("g3 1 1 0\n");
9959        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9960        s.push_str(&format!(" {m} 0\n 0 0\n"));
9961        s.push_str(&format!(" {n} 0 0\n"));
9962        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9963        s.push_str(&format!(" {nzc} {n}\n"));
9964        s.push_str(" 0 0\n 0 2 0 0 0\n");
9965        // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
9966        // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
9967        for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
9968            s.push_str(&format!("V{} 0 0\n", n + b));
9969            s.push_str("o41\n");
9970            for _ in 0..count - 1 {
9971                s.push_str("o0\n");
9972            }
9973            for j in 0..*count {
9974                s.push_str(&format!("v{}\n", base + j));
9975            }
9976        }
9977        // Rows: body_b * x_{rowvar}.
9978        for i in 0..m {
9979            let b = i / rows;
9980            s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
9981        }
9982        s.push_str("O0 0\nn0\n");
9983        s.push_str(&format!("x{n}\n"));
9984        for j in 0..n {
9985            s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
9986        }
9987        s.push_str("r\n");
9988        for _ in 0..m {
9989            s.push_str("2 0\n");
9990        }
9991        s.push_str("b\n");
9992        for _ in 0..n {
9993            s.push_str("3\n");
9994        }
9995        // Cumulative Jacobian column counts for the first n-1 columns.
9996        s.push_str(&format!("k{}\n", n - 1));
9997        let mut acc = 0;
9998        for j in 0..n - 1 {
9999            acc += if j < nvars { rows } else { 1 };
10000            s.push_str(&format!("{acc}\n"));
10001        }
10002        for i in 0..m {
10003            let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
10004            s.push_str(&format!("J{i} {}\n", count + 1));
10005            let mut cols: Vec<usize> = (base..base + count).collect();
10006            cols.push(nvars + i);
10007            cols.sort_unstable();
10008            for c in cols {
10009                s.push_str(&format!("{c} 0.0\n"));
10010            }
10011        }
10012        s.push_str(&format!("G0 {n}\n"));
10013        for j in 0..n {
10014            s.push_str(&format!("{j} 0.0\n"));
10015        }
10016        s
10017    }
10018
10019    /// Both prelude sweeps run once per color, so iterating the whole prelude
10020    /// each time would cost `n_colors × |prelude|` where the op-ratio gate
10021    /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
10022    /// `eval_h` instead walks the union of that color's summands'
10023    /// `prelude_reach`.
10024    ///
10025    /// What this can and cannot pin is worth being exact about, because the
10026    /// change is pure performance: walking the whole prelude per color
10027    /// computes the *same* Hessian, so no assertion on output values can
10028    /// detect it, and a timing assertion would be flaky. So this asserts the
10029    /// two things that are checkable — that the table the sweeps iterate is
10030    /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
10031    /// where colors genuinely reach different bodies, and that each reach list
10032    /// is ascending and operand-closed, the invariants that make the narrowed
10033    /// walk safe — plus agreement with the flat tapes under narrowing.
10034    #[test]
10035    fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
10036        let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
10037        let mut hybrid = NlTnlp::new(p.clone());
10038        let info = hybrid.get_nlp_info().unwrap();
10039        let nnz = info.nnz_h_lag as usize;
10040        let m = info.m as usize;
10041
10042        {
10043            let h = hybrid
10044                .con_hybrid
10045                .as_mut()
10046                .expect("two shared CSE bodies must build the hybrid tape");
10047            h.use_for_hess = true;
10048
10049            let np = h.tape.n_prelude_ops();
10050            let n_colors = h.hess_color_reach_off.len() - 1;
10051            let total: usize = h.hess_color_reach.len();
10052            assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
10053            assert!(
10054                total < n_colors * np,
10055                "per-color reach must be strictly smaller than walking the whole \
10056                 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
10057                n_colors * np
10058            );
10059            // Every reach list must be ascending and operand-closed, which is
10060            // what makes the narrowed walk safe.
10061            for c in 0..n_colors {
10062                let r =
10063                    &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
10064                assert!(
10065                    r.windows(2).all(|w| w[0] < w[1]),
10066                    "color {c} reach is not strictly ascending"
10067                );
10068                let member: std::collections::HashSet<u32> = r.iter().copied().collect();
10069                for &i in r {
10070                    let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
10071                    for opnd in [a, b].into_iter().flatten() {
10072                        assert!(
10073                            member.contains(&(opnd as u32)),
10074                            "color {c}: slot {i}'s operand {opnd} is missing from its reach"
10075                        );
10076                    }
10077                }
10078            }
10079        }
10080
10081        let mut flat = NlTnlp::new(p);
10082        flat.get_nlp_info().unwrap();
10083        flat.con_hybrid = None;
10084
10085        let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
10086        for scale in [1.0_f64, -0.6] {
10087            let x: Vec<f64> = (0..info.n as usize)
10088                .map(|j| scale * (0.15 + 0.02 * j as f64))
10089                .collect();
10090            let mut hh = vec![0.0_f64; nnz];
10091            let mut hf = vec![0.0_f64; nnz];
10092            assert!(hybrid.eval_h(
10093                Some(&x),
10094                true,
10095                1.0,
10096                Some(&lam),
10097                true,
10098                SparsityRequest::Values { values: &mut hh }
10099            ));
10100            assert!(flat.eval_h(
10101                Some(&x),
10102                true,
10103                1.0,
10104                Some(&lam),
10105                true,
10106                SparsityRequest::Values { values: &mut hf }
10107            ));
10108            for k in 0..nnz {
10109                let tol = 1e-12 * hf[k].abs().max(1.0);
10110                assert!(
10111                    (hh[k] - hf[k]).abs() <= tol,
10112                    "narrowed-reach Hessian entry {k} at scale {scale}: \
10113                     hybrid {} vs flat {}",
10114                    hh[k],
10115                    hf[k]
10116                );
10117            }
10118            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10119        }
10120    }
10121
10122    /// The Hessian gate is off for a model whose shared bodies are small —
10123    /// below the ratio where the shared prelude sweeps pay for the hybrid
10124    /// traversal's per-op overhead — leaving `eval_h` on the flat path
10125    /// (which the gate keeps bit-identical for such models by definition).
10126    #[test]
10127    fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
10128        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10129        let mut t = NlTnlp::new(p);
10130        t.get_nlp_info().unwrap();
10131        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10132        assert!(
10133            !h.use_for_hess,
10134            "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
10135        );
10136    }
10137
10138    /// A CSE referenced from several constraints is evaluated once per
10139    /// `eval_g` via the shared prelude instead of once per reference. The
10140    /// values must be bit-identical to the flat per-summand `Tape` path,
10141    /// which is what makes the optimization safe to apply unconditionally.
10142    #[test]
10143    fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
10144        let nl = shared_cse_nl("o2\nv3\nv2");
10145        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10146        let mut hybrid = NlTnlp::new(p.clone());
10147        hybrid.get_nlp_info().unwrap();
10148        let h = hybrid
10149            .con_hybrid
10150            .as_ref()
10151            .expect("CSE shared by 3 constraints must take the hybrid path");
10152        assert!(
10153            h.tape.n_prelude_ops() > 0,
10154            "shared CSE body must land in the prelude"
10155        );
10156
10157        // Same model with the hybrid path switched off: the reference.
10158        let mut flat = NlTnlp::new(p);
10159        flat.get_nlp_info().unwrap();
10160        flat.con_hybrid = None;
10161
10162        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10163            let mut gh = [0.0_f64; 3];
10164            let mut gf = [0.0_f64; 3];
10165            assert!(hybrid.eval_g(&x, true, &mut gh));
10166            assert!(flat.eval_g(&x, true, &mut gf));
10167            // V3 = 2 x0 + 3 x1.
10168            let s = 2.0 * x[0] + 3.0 * x[1];
10169            let want = [s * s, s * s * s + x[2], s * x[2]];
10170            for i in 0..3 {
10171                assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
10172                assert!(
10173                    (gh[i] - want[i]).abs() < 1e-9,
10174                    "row {i}: got {}, want {}",
10175                    gh[i],
10176                    want[i]
10177                );
10178            }
10179        }
10180    }
10181
10182    /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
10183    /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
10184    /// take that path after `hybrid_supported` clears the model. Here a
10185    /// min-list in one constraint has to disable it for the whole block —
10186    /// falling back, not panicking.
10187    #[test]
10188    fn unsupported_opcode_falls_back_to_the_flat_tape() {
10189        let nl = shared_cse_nl("o11\n2\nv3\nv2");
10190        let p = parse_nl_text(&nl).expect("parse min-list model");
10191        let mut tnlp = NlTnlp::new(p);
10192        tnlp.get_nlp_info().unwrap();
10193        assert!(
10194            tnlp.con_hybrid.is_none(),
10195            "a min-list anywhere in the constraint block must disable the hybrid path"
10196        );
10197        let mut g = [0.0_f64; 3];
10198        assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
10199        let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
10200        assert!((g[0] - s * s).abs() < 1e-9);
10201        assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
10202    }
10203
10204    // ---- In-memory construction + HVP (issue #469) --------------------
10205
10206    fn v(i: usize) -> Expr {
10207        Expr::Var(i)
10208    }
10209
10210    fn c(x: Number) -> Expr {
10211        Expr::Const(x)
10212    }
10213
10214    fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
10215        Expr::Binary(op, Box::new(a), Box::new(b))
10216    }
10217
10218    fn un(op: UnaryOp, a: Expr) -> Expr {
10219        Expr::Unary(op, Box::new(a))
10220    }
10221
10222    /// `NlProblemParts` for an `n`-variable, unbounded model.
10223    fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
10224        let m = constraints.len();
10225        NlProblemParts {
10226            minimize: true,
10227            objective,
10228            obj_constant: 0.0,
10229            constraints,
10230            x_l: vec![-1e19; n],
10231            x_u: vec![1e19; n],
10232            x0: vec![0.0; n],
10233            g_l: vec![-1e19; m],
10234            g_u: vec![1e19; m],
10235            var_names: Vec::new(),
10236            con_names: Vec::new(),
10237        }
10238    }
10239
10240    /// A model built from expressions evaluates exactly like a parsed one:
10241    /// objective, gradient, constraints, and Jacobian all come from the
10242    /// same tape, with no `.nl` text in the loop.
10243    #[test]
10244    fn from_expressions_builds_evaluable_problem() {
10245        // min (1-x0)^2 + 100*(x1 - x0^2)^2   s.t.  x0^2 + x1^2 <= 2
10246        let rosen = bin(
10247            BinOp::Add,
10248            bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
10249            bin(
10250                BinOp::Mul,
10251                c(100.0),
10252                bin(
10253                    BinOp::Pow,
10254                    bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
10255                    c(2.0),
10256                ),
10257            ),
10258        );
10259        let circle = bin(
10260            BinOp::Add,
10261            bin(BinOp::Pow, v(0), c(2.0)),
10262            bin(BinOp::Pow, v(1), c(2.0)),
10263        );
10264
10265        let mut p = parts(2, rosen, vec![circle]);
10266        p.g_l = vec![0.0];
10267        p.g_u = vec![2.0];
10268        p.x0 = vec![-1.2, 1.0];
10269        p.var_names = names(&["x", "y"]);
10270        p.con_names = names(&["circle"]);
10271
10272        let prob = NlProblem::from_expressions(p).expect("build");
10273        assert_eq!((prob.n, prob.m), (2, 1));
10274        assert_eq!(prob.var_names, names(&["x", "y"]));
10275
10276        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10277        t.get_nlp_info().unwrap();
10278
10279        // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
10280        let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
10281        assert!((f - 24.2).abs() < 1e-10, "f = {f}");
10282
10283        // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
10284        //    = (4.4 + 480*(-0.44)... ) — computed below rather than
10285        //      transcribed, so the check is the formula, not an editor.
10286        let (x0, x1) = (-1.2, 1.0);
10287        let want = [
10288            -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
10289            200.0 * (x1 - x0 * x0),
10290        ];
10291        let mut g = [0.0_f64; 2];
10292        assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
10293        for j in 0..2 {
10294            assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
10295        }
10296
10297        // g(x) = x0^2 + x1^2 = 2.44
10298        let mut gv = [0.0_f64; 1];
10299        assert!(t.eval_g(&[x0, x1], true, &mut gv));
10300        assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
10301    }
10302
10303    /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
10304    /// this path. None of the three survives a `.nl` round trip in a
10305    /// typical frontend — `atan2` has no two-argument funcall path,
10306    /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
10307    /// at all — which is the reason the in-memory door exists.
10308    #[test]
10309    fn from_expressions_carries_ops_nl_cannot_express() {
10310        let obj = Expr::Sum(vec![
10311            bin(BinOp::Atan2, v(0), v(1)),
10312            Expr::MinList(vec![v(0), v(1)]),
10313            Expr::MaxList(vec![v(0), v(1)]),
10314            un(UnaryOp::Erf, v(0)),
10315        ]);
10316        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10317        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10318        t.get_nlp_info().unwrap();
10319
10320        let x: [Number; 2] = [0.8, 1.5];
10321        // atan2 + min + max + erf; min+max == x0+x1 for any pair.
10322        let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
10323        let f = t.eval_f(&x, true).unwrap();
10324        assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
10325    }
10326
10327    /// A `Var` index past `n` would be an out-of-bounds read in the
10328    /// tape's forward sweep. It has to be caught at construction, while
10329    /// it is still a diagnosable user error.
10330    #[test]
10331    fn from_expressions_rejects_out_of_range_var() {
10332        let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
10333            .expect_err("Var(5) with n = 2 must be rejected");
10334        assert!(err.contains("Var(5)"), "{err}");
10335
10336        let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
10337            .expect_err("constraint Var(2) with n = 2 must be rejected");
10338        assert!(err.contains("constraint 0"), "{err}");
10339
10340        // Length mismatches are errors too, not panics.
10341        let mut p = parts(2, c(0.0), Vec::new());
10342        p.x0 = vec![0.0; 3];
10343        let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
10344        assert!(err.contains("x0"), "{err}");
10345    }
10346
10347    /// An out-of-range `Var` must be caught wherever it hides, not just at
10348    /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
10349    /// branch, and a funcall argument all reach the same forward sweep.
10350    #[test]
10351    fn from_expressions_finds_out_of_range_vars_in_every_position() {
10352        let inner_cse = Arc::new(v(7));
10353        let cases: Vec<(&str, Expr)> = vec![
10354            ("bare", v(7)),
10355            ("cse", Expr::Cse(Arc::new(v(7)))),
10356            ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
10357            (
10358                "cond branch",
10359                Expr::Cond {
10360                    cond: Box::new(c(1.0)),
10361                    then_: Box::new(v(7)),
10362                    else_: Box::new(c(0.0)),
10363                },
10364            ),
10365            ("min list", Expr::MinList(vec![c(0.0), v(7)])),
10366            (
10367                "sum",
10368                Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
10369            ),
10370        ];
10371        for (label, e) in cases {
10372            let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
10373                .err()
10374                .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
10375            assert!(err.contains("Var(7)"), "{label}: {err}");
10376        }
10377    }
10378
10379    /// A balanced share-DAG: each level is one `Cse` whose body
10380    /// references the level below twice. Depth `d` is `d` distinct nodes
10381    /// but `2^d` paths, so any walk that re-enters a shared body per
10382    /// occurrence is Θ(2^d).
10383    fn share_dag(depth: usize) -> Expr {
10384        let mut e = v(0);
10385        for _ in 0..depth {
10386            let shared = Arc::new(e);
10387            e = bin(
10388                BinOp::Add,
10389                Expr::Cse(Arc::clone(&shared)),
10390                Expr::Cse(shared),
10391            );
10392        }
10393        e
10394    }
10395
10396    /// The walks over a shared DAG must be memoized, not exponential.
10397    ///
10398    /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
10399    /// does not "fail" so much as never finish, which is exactly the
10400    /// signal. `from_expressions` is the door that makes such a DAG
10401    /// trivially constructible, but the blowup was reachable through
10402    /// `collect_vars` (which presolve calls on every solve, via
10403    /// `get_variables_linearity`) and `collect_funcall_ids` (which
10404    /// `NlTnlp::try_new` runs over every row).
10405    #[test]
10406    fn shared_dag_walks_are_memoized_not_exponential() {
10407        const DEPTH: usize = 30;
10408        let e = share_dag(DEPTH);
10409
10410        let mut vars = BTreeSet::new();
10411        collect_vars(&e, &mut vars);
10412        assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
10413
10414        let mut ids = BTreeSet::new();
10415        super::super::nl_external::collect_funcall_ids(&e, &mut ids);
10416        assert!(ids.is_empty());
10417
10418        // The whole build path, end to end: validation, tape construction,
10419        // and the linearity metadata presolve consumes.
10420        let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
10421        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10422        t.get_nlp_info().unwrap();
10423        let mut lin = vec![Linearity::Linear; 1];
10424        assert!(t.get_variables_linearity(&mut lin));
10425    }
10426
10427    /// `from_expressions` cannot carry AMPL imported functions — there is
10428    /// nowhere to put the `F`-segment declarations that bind a funcall id
10429    /// to a library — so a `Funcall` must be refused up front. Accepting it
10430    /// produces "AMPLFUNC is not set", which the user cannot act on:
10431    /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
10432    #[test]
10433    fn from_expressions_rejects_imported_function_calls() {
10434        let call = Expr::Funcall {
10435            id: 0,
10436            args: vec![FuncallArg::Real(v(0))],
10437        };
10438        let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
10439            .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
10440        assert!(err.contains("imported function"), "{err}");
10441        assert!(
10442            err.contains("read_nl") || err.contains("parse_nl_text"),
10443            "the error must point at the paths that do support externals: {err}"
10444        );
10445
10446        // Also when buried in a constraint, behind a Cse.
10447        let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
10448        let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
10449            .expect_err("a buried Funcall must be rejected too");
10450        assert!(err.contains("constraint 0"), "{err}");
10451    }
10452
10453    /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
10454    /// same tapes, same weights, one seed instead of a color sweep. The
10455    /// objective and both constraints are chosen with cross terms so the
10456    /// off-diagonal blocks actually carry signal.
10457    #[test]
10458    fn hessian_vector_product_matches_dense_hessian() {
10459        let obj = Expr::Sum(vec![
10460            bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
10461            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10462            un(UnaryOp::Erf, v(2)),
10463        ]);
10464        let cons = vec![
10465            bin(
10466                BinOp::Add,
10467                bin(BinOp::Pow, v(0), c(2.0)),
10468                un(UnaryOp::Sin, v(2)),
10469            ),
10470            bin(BinOp::Mul, v(1), v(2)),
10471        ];
10472        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10473        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10474        let info = t.get_nlp_info().unwrap();
10475
10476        let x = [0.3, -0.7, 1.1];
10477        let lam = [0.5, -1.25];
10478        let obj_factor = 2.0;
10479
10480        // Dense Hessian from the sparse lower triangle.
10481        let nnz = info.nnz_h_lag as usize;
10482        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10483        assert!(t.eval_h(
10484            None,
10485            false,
10486            1.0,
10487            None,
10488            false,
10489            SparsityRequest::Structure {
10490                irow: &mut irow,
10491                jcol: &mut jcol
10492            }
10493        ));
10494        let mut hvals = vec![0.0_f64; nnz];
10495        assert!(t.eval_h(
10496            Some(&x),
10497            true,
10498            obj_factor,
10499            Some(&lam),
10500            true,
10501            SparsityRequest::Values { values: &mut hvals }
10502        ));
10503        let mut dense = [[0.0_f64; 3]; 3];
10504        for k in 0..nnz {
10505            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10506            dense[i][j] += hvals[k];
10507            if i != j {
10508                dense[j][i] += hvals[k];
10509            }
10510        }
10511
10512        // Each unit seed recovers a column; a mixed seed catches an HVP
10513        // that only happens to be right on the basis vectors.
10514        let seeds: [[Number; 3]; 4] = [
10515            [1.0, 0.0, 0.0],
10516            [0.0, 1.0, 0.0],
10517            [0.0, 0.0, 1.0],
10518            [0.4, -1.3, 2.0],
10519        ];
10520        let mut out = vec![0.0; 3];
10521        for s in &seeds {
10522            t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
10523                .expect("hvp");
10524            for i in 0..3 {
10525                let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
10526                assert!(
10527                    (out[i] - want).abs() < 1e-9,
10528                    "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
10529                    out[i]
10530                );
10531            }
10532        }
10533    }
10534
10535    /// `lam = None` is the objective block alone, and `out` is
10536    /// overwritten (not accumulated) so a reused buffer is safe.
10537    #[test]
10538    fn hessian_vector_product_defaults_and_validation() {
10539        // f = x0^2 + 3 x0 x1  ->  ∇²f = [[2, 3], [3, 0]]
10540        let obj = bin(
10541            BinOp::Add,
10542            bin(BinOp::Pow, v(0), c(2.0)),
10543            bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
10544        );
10545        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10546        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10547        t.get_nlp_info().unwrap();
10548
10549        let mut out = vec![7.0, -7.0]; // dirty buffer
10550        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
10551            .expect("hvp");
10552        assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
10553        assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
10554
10555        // obj_factor scales linearly.
10556        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
10557            .expect("hvp");
10558        assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
10559
10560        // Length mismatches are errors, not panics or silent truncation.
10561        let mut short = vec![0.0; 1];
10562        assert!(
10563            t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
10564                .is_err()
10565        );
10566        assert!(
10567            t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
10568                .is_err()
10569        );
10570        assert!(
10571            t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
10572                .is_err()
10573        );
10574    }
10575
10576    /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
10577    /// Hessian — the sparse shape an IPM actually meets. The block HVP has
10578    /// to reproduce it column for column, including the structural zeros:
10579    /// a bug that leaked coupling between non-adjacent variables would
10580    /// show up here and nowhere in a small dense test.
10581    #[test]
10582    fn hessian_vector_products_on_a_sparse_hessian() {
10583        const N: usize = 8;
10584        let mut terms = Vec::new();
10585        for i in 0..N - 1 {
10586            terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
10587        }
10588        for i in 0..N {
10589            terms.push(un(UnaryOp::Exp, v(i)));
10590        }
10591        let prob =
10592            NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
10593        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10594        let info = t.get_nlp_info().unwrap();
10595
10596        // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
10597        assert_eq!(
10598            info.nnz_h_lag as usize,
10599            2 * N - 1,
10600            "chain objective should give a tridiagonal Hessian, not a dense one"
10601        );
10602
10603        let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
10604
10605        // Densify the sparse triangle for the reference.
10606        let nnz = info.nnz_h_lag as usize;
10607        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10608        assert!(t.eval_h(
10609            None,
10610            false,
10611            1.0,
10612            None,
10613            false,
10614            SparsityRequest::Structure {
10615                irow: &mut irow,
10616                jcol: &mut jcol
10617            }
10618        ));
10619        let mut hvals = vec![0.0; nnz];
10620        assert!(t.eval_h(
10621            Some(&x),
10622            true,
10623            1.0,
10624            None,
10625            true,
10626            SparsityRequest::Values { values: &mut hvals }
10627        ));
10628        let mut dense = vec![vec![0.0; N]; N];
10629        for k in 0..nnz {
10630            let (i, j) = (irow[k] as usize, jcol[k] as usize);
10631            dense[i][j] += hvals[k];
10632            if i != j {
10633                dense[j][i] += hvals[k];
10634            }
10635        }
10636
10637        // One block of N unit seeds recovers the whole matrix — the
10638        // "densify via HVPs" path, in one call.
10639        let mut seeds = vec![0.0; N * N];
10640        for cc in 0..N {
10641            seeds[cc * N + cc] = 1.0;
10642        }
10643        let mut out = vec![0.0; N * N];
10644        t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
10645            .expect("block hvp");
10646        for cc in 0..N {
10647            for i in 0..N {
10648                assert!(
10649                    (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
10650                    "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
10651                    out[cc * N + i],
10652                    dense[i][cc]
10653                );
10654            }
10655        }
10656    }
10657
10658    /// The block form must agree with `k` separate single-vector calls
10659    /// (it shares one forward sweep across directions, so a bug there
10660    /// would show as a per-direction discrepancy), and must skip an
10661    /// all-zero direction without disturbing its neighbours.
10662    #[test]
10663    fn hessian_vector_products_match_repeated_single_calls() {
10664        let obj = Expr::Sum(vec![
10665            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10666            bin(BinOp::Pow, v(2), c(4.0)),
10667            bin(BinOp::Mul, v(0), v(2)),
10668        ]);
10669        let cons = vec![bin(BinOp::Mul, v(1), v(2))];
10670        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10671        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10672        t.get_nlp_info().unwrap();
10673
10674        let x = [0.4, -0.6, 1.3];
10675        let lam = [0.75];
10676        let cols: [[Number; 3]; 4] = [
10677            [1.0, 2.0, -3.0],
10678            [0.0, 0.0, 0.0], // the skipped direction
10679            [0.5, 0.0, 0.0],
10680            [-1.0, 1.0, 1.0],
10681        ];
10682
10683        let mut block = vec![0.0; 3 * cols.len()];
10684        let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
10685        t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
10686            .expect("block hvp");
10687
10688        for (c, col) in cols.iter().enumerate() {
10689            let mut single = vec![0.0; 3];
10690            t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
10691                .expect("single hvp");
10692            for i in 0..3 {
10693                assert!(
10694                    (block[c * 3 + i] - single[i]).abs() < 1e-12,
10695                    "direction {c} row {i}: block={:.12e} single={:.12e}",
10696                    block[c * 3 + i],
10697                    single[i]
10698                );
10699            }
10700        }
10701        // The zero direction really is zero, not stale scratch.
10702        assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
10703    }
10704
10705    /// `k = 0` is a legal empty block, and the length checks scale with
10706    /// `k` rather than assuming a single direction.
10707    #[test]
10708    fn hessian_vector_products_validate_block_shape() {
10709        let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
10710            .expect("build");
10711        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10712        t.get_nlp_info().unwrap();
10713
10714        let mut empty: Vec<Number> = Vec::new();
10715        assert!(
10716            t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
10717                .is_ok()
10718        );
10719
10720        // v sized for one direction while k says two.
10721        let mut out = vec![0.0; 4];
10722        assert!(
10723            t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
10724                .is_err()
10725        );
10726        // out sized for one direction while k says two.
10727        let mut short = vec![0.0; 2];
10728        assert!(
10729            t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
10730                .is_err()
10731        );
10732    }
10733
10734    /// A `maximize` model's objective is negated by the evaluator, and
10735    /// the HVP has to agree with `eval_h` about that — otherwise a
10736    /// Hessian-free step would climb where the sparse path descends.
10737    #[test]
10738    fn hessian_vector_product_respects_maximize_sign() {
10739        let obj = bin(BinOp::Pow, v(0), c(2.0));
10740        let mut p = parts(1, obj, Vec::new());
10741        p.minimize = false;
10742        let prob = NlProblem::from_expressions(p).expect("build");
10743        let mut t = NlTnlp::try_new(prob).expect("tnlp");
10744        t.get_nlp_info().unwrap();
10745
10746        // max x0^2 is minimized as -x0^2, so ∇² = -2.
10747        let mut out = vec![0.0; 1];
10748        t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
10749            .expect("hvp");
10750        assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
10751    }
10752}