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    pub lambda0: Vec<Number>,
695    /// AMPL suffix dictionaries. Variable / constraint / objective
696    /// suffixes are stored as dense vectors (length n / m / num_obj)
697    /// with the sparse `.nl` `S`-segment entries scattered in, default
698    /// zero. The integer / real split matches the `S`-segment header's
699    /// kind bit (`0x4` ⇒ real, else integer). See
700    /// <https://ampl.com/REFS/hooking2.pdf> §6 and the upstream `.nl`
701    /// reader in `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`.
702    pub suffixes: NlSuffixes,
703    /// The model's own AMPL option words, taken verbatim from `.nl`
704    /// header line 0 (`g<count> <opt0> <opt1> ...`). A solver echoes
705    /// these back in the `.sol` `Options` block rather than interpreting
706    /// them — see [`crate::sol_writer::format_sol_with_options`]. Empty
707    /// for problems not built from a `.nl` file.
708    pub ampl_options: Vec<i64>,
709    /// The header's nonlinearity census, when the problem came from a `.nl`
710    /// file whose header parsed cleanly. `None` for a model built in memory
711    /// ([`NlProblem::from_expressions`]) — there is no header to read, and
712    /// inventing one would let a consumer trust a count nobody computed.
713    pub nl_counts: Option<NlCounts>,
714    /// AMPL imported (external) functions declared via top-level `F` segments.
715    /// Empty unless the `.nl` file calls compiled-C user functions (typically
716    /// emitted by IDAES property packages — see issue #49).
717    pub imported_funcs: Vec<ImportedFunc>,
718    /// Variable names from the sibling `.col` file, index-aligned to `x`
719    /// (one name per line, column order). Empty when no `.col` file was
720    /// found — AMPL only emits it under `option auxfiles rc;`.
721    ///
722    /// Carrying names lets diagnostics report `flow_balance` / `T_reactor`
723    /// instead of `c[3]` / `x[132]`. Lee et al. (2024) identify the gap
724    /// between detecting an issue and tracing it to a *named* equation as a
725    /// central roadblock for equation-oriented model debugging; threading
726    /// names through to the solver/debugger is the prerequisite for closing
727    /// it. See <https://doi.org/10.69997/sct.147875>.
728    pub var_names: Vec<String>,
729    /// Constraint names from the sibling `.row` file, index-aligned to `g`
730    /// (one name per line, row order). Empty when no `.row` file was found.
731    /// See [`NlProblem::var_names`] for why names are captured.
732    pub con_names: Vec<String>,
733    /// The `.nl` text this problem was parsed from, kept only when some
734    /// body was recognized as a quadratic and therefore has no tree of its
735    /// own. It is what makes [`NlProblem::con_expr`] able to hand back the
736    /// *exact* tree rather than a re-derived one: same bytes, same parser,
737    /// same `Expr`. `None` for [`NlProblem::from_expressions`] models and
738    /// for a parse that recognized nothing.
739    ///
740    /// The text is resident during parsing either way, so keeping it costs
741    /// nothing at the peak this is all in aid of — see
742    /// `dev-notes/quadratic-structure-exploitation.md` §0f.
743    pub src: Option<Arc<String>>,
744    /// The `V`-segment common subexpressions, by CSE-local index. Held so a
745    /// re-parse resolves `v<i>` (`i >= n`) to the *same* `Arc` the original
746    /// parse did — `HybridTape::build_multi` keys sharing on pointer
747    /// identity, so a rebuilt body that allocated fresh bodies would look
748    /// unshared.
749    pub cse_bodies: Vec<Arc<Expr>>,
750}
751
752/// The pieces of a model built in memory, as handed to
753/// [`NlProblem::from_expressions`].
754///
755/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
756/// split to fill in, because the AD tape treats a linear term exactly like
757/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
758/// optimization, not an evaluator requirement). `n` is taken from the length
759/// of `x_l`; `m` from the length of `constraints`.
760///
761/// One cost to that simplification, in *metadata* rather than values: with
762/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
763/// when its expression is literally `Const(0.0)`, so a genuinely linear row
764/// built here reports `NonLinear`. Presolve consumes that tag, and the
765/// direction is the safe one — it loses tightening it could have done, and
766/// never asserts linearity that does not hold — but a frontend that cares
767/// about presolve strength on linear rows should know the tag is
768/// pessimistic on this path.
769#[derive(Debug, Clone)]
770pub struct NlProblemParts {
771    /// `true` to minimize `objective`, `false` to maximize it. Matches
772    /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
773    /// so callers always see the minimization form.
774    pub minimize: bool,
775    /// Objective expression.
776    pub objective: Expr,
777    /// Constant offset added to the objective.
778    pub obj_constant: Number,
779    /// One expression per constraint row; row `i` is bounded by
780    /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
781    pub constraints: Vec<Expr>,
782    /// Variable bounds and starting point, each length `n`. Use `±1e19`
783    /// for "unbounded", the same sentinel the `.nl` reader emits.
784    pub x_l: Vec<Number>,
785    pub x_u: Vec<Number>,
786    pub x0: Vec<Number>,
787    /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
788    /// equality row.
789    pub g_l: Vec<Number>,
790    pub g_u: Vec<Number>,
791    /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
792    /// consumer falls back to indices (see [`NlProblem::var_names`]).
793    pub var_names: Vec<String>,
794    pub con_names: Vec<String>,
795}
796
797impl NlProblem {
798    /// Assemble a problem from expression trees, with no `.nl` file
799    /// anywhere in the loop (issue #469).
800    ///
801    /// A modeling frontend that already has its own expression DAG should
802    /// come in here rather than serialize to `.nl` and re-parse: the round
803    /// trip is not only slower, it is *lossy*, because `.nl` writers
804    /// routinely refuse operators this tape supports natively (`atan2`,
805    /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
806    ///
807    /// The result is an ordinary [`NlProblem`], so it feeds
808    /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
809    /// does: objective, gradient, constraints, Jacobian + structure,
810    /// Lagrangian Hessian + structure, and
811    /// [`NlTnlp::hessian_vector_product`].
812    ///
813    /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
814    /// — the latter would otherwise be an out-of-bounds read in the tape's
815    /// forward sweep, so it must be caught while it is still a diagnosable
816    /// user error.
817    pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
818        let NlProblemParts {
819            minimize,
820            objective,
821            obj_constant,
822            constraints,
823            x_l,
824            x_u,
825            x0,
826            g_l,
827            g_u,
828            var_names,
829            con_names,
830        } = parts;
831
832        let n = x_l.len();
833        let m = constraints.len();
834        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
835            if got == want {
836                Ok(())
837            } else {
838                Err(format!(
839                    "from_expressions: {name} has length {got}, expected {want}"
840                ))
841            }
842        };
843        check("x_u", x_u.len(), n)?;
844        check("x0", x0.len(), n)?;
845        check("g_l", g_l.len(), m)?;
846        check("g_u", g_u.len(), m)?;
847        if !var_names.is_empty() {
848            check("var_names", var_names.len(), n)?;
849        }
850        if !con_names.is_empty() {
851            check("con_names", con_names.len(), m)?;
852        }
853
854        // Numeric validation, the same screen the `.nl` reader applies
855        // (gh #847). `lower_bound_present` / `upper_bound_present` are
856        // `is_finite() && ...`, so a caller that builds a model here and hands
857        // in a non-finite bound gets it silently dropped -- read as "no bound
858        // declared" -- exactly as a file containing `1e400` was. `-inf` on a
859        // lower side and `+inf` on an upper one are the sentinel said a
860        // different way and are normalized; everything else, `NaN` included,
861        // is refused.
862        let mut x_l = x_l;
863        let mut x_u = x_u;
864        let mut g_l = g_l;
865        let mut g_u = g_u;
866        for (i, v) in x_l.iter_mut().enumerate() {
867            *v = finite_bound_or_err(&format!("x_l[{i}]"), *v, true)
868                .map_err(|e| format!("from_expressions: {e}"))?;
869        }
870        for (i, v) in x_u.iter_mut().enumerate() {
871            *v = finite_bound_or_err(&format!("x_u[{i}]"), *v, false)
872                .map_err(|e| format!("from_expressions: {e}"))?;
873        }
874        for (i, v) in g_l.iter_mut().enumerate() {
875            *v = finite_bound_or_err(&format!("g_l[{i}]"), *v, true)
876                .map_err(|e| format!("from_expressions: {e}"))?;
877        }
878        for (i, v) in g_u.iter_mut().enumerate() {
879            *v = finite_bound_or_err(&format!("g_u[{i}]"), *v, false)
880                .map_err(|e| format!("from_expressions: {e}"))?;
881        }
882        for (i, v) in x0.iter().enumerate() {
883            finite_or_err(&format!("x0[{i}]"), *v).map_err(|e| format!("from_expressions: {e}"))?;
884        }
885        finite_or_err("obj_constant", obj_constant)
886            .map_err(|e| format!("from_expressions: {e}"))?;
887
888        // Structural validation. Memoized on `Cse` pointer identity so a
889        // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
890        let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
891        validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
892        for (i, c) in constraints.iter().enumerate() {
893            validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
894        }
895
896        Ok(NlProblem {
897            n,
898            m,
899            num_obj: 1,
900            minimize,
901            obj_nonlinear: NlBody::Tree(objective),
902            obj_linear: Vec::new(),
903            obj_constant,
904            con_nonlinear: constraints.into_iter().map(NlBody::Tree).collect(),
905            con_linear: vec![Vec::new(); m],
906            x_l,
907            x_u,
908            g_l,
909            g_u,
910            x0,
911            lambda0: vec![0.0; m],
912            suffixes: NlSuffixes::default(),
913            imported_funcs: Vec::new(),
914            ampl_options: Vec::new(),
915            // No header was read, so there is no census to report. Consumers
916            // fall back to walking the trees, which is what they would have
917            // to do here anyway.
918            nl_counts: None,
919            var_names,
920            con_names,
921            // Built from trees, so every body has one and there is nothing
922            // to rebuild from.
923            src: None,
924            cse_bodies: Vec::new(),
925        })
926    }
927
928    /// The objective's nonlinear body as an [`Expr`].
929    ///
930    /// Borrowed when the tree is resident, rebuilt when the parser
931    /// recognized the body and skipped building it. The rebuild re-parses
932    /// the body's own bytes with the same parser that produced the rest of
933    /// the model, so the result is the tree a non-recognizing parse would
934    /// have produced — structurally identical, coefficient bit patterns
935    /// included, and sharing the same `Cse` allocations.
936    ///
937    /// It is not free: it allocates the nodes the recognizer avoided. Reach
938    /// for [`NlBody::quad`] first if the coefficients are what you want.
939    pub fn obj_expr(&self) -> std::borrow::Cow<'_, Expr> {
940        self.body_expr(&self.obj_nonlinear, "objective")
941    }
942
943    /// Row `k`'s nonlinear body as an [`Expr`]. See [`Self::obj_expr`].
944    ///
945    /// # Panics
946    ///
947    /// Panics if `k >= m`, or if re-parsing a recognized body fails — the
948    /// latter cannot happen for a problem this crate produced (the bytes
949    /// parsed once already, by this code) and means the `NlProblem` was
950    /// assembled by hand with a `src` that does not match its bodies.
951    pub fn con_expr(&self, k: usize) -> std::borrow::Cow<'_, Expr> {
952        self.body_expr(&self.con_nonlinear[k], "constraint")
953    }
954
955    fn body_expr<'a>(&'a self, body: &'a NlBody, what: &str) -> std::borrow::Cow<'a, Expr> {
956        match body {
957            NlBody::Tree(e) => std::borrow::Cow::Borrowed(e),
958            NlBody::Quad(q) => {
959                let src = self
960                    .src
961                    .as_deref()
962                    .unwrap_or_else(|| panic!("{what} body was recognized but no source is kept"));
963                std::borrow::Cow::Owned(
964                    parse_body_fragment(&src[q.src.clone()], self.n, &self.cse_bodies)
965                        .unwrap_or_else(|e| panic!("re-parsing a recognized {what} body: {e}")),
966                )
967            }
968        }
969    }
970}
971
972/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
973/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
974///
975/// Both are things the tape cannot recover from later. An out-of-range
976/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
977/// worse-looking than it is fatal: `from_expressions` has nowhere to put
978/// the `F`-segment declarations an AMPL imported function needs
979/// (`NlProblemParts` has no field for them, and the built problem's
980/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
981/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
982/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
983/// failure to "funcall id N has no F<N> declaration". Rejecting it here
984/// says the true thing: this door does not carry external functions; go
985/// through `read_nl` / `parse_nl_text` for a model that needs them.
986///
987/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
988/// passing one set through a whole problem keeps the walk linear in
989/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
990/// repeat visit is sound because the caller aborts on the first violation:
991/// reaching a body a second time proves the first visit found none.
992fn validate_expr(
993    e: &Expr,
994    n: usize,
995    seen: &mut std::collections::HashSet<*const Expr>,
996) -> Result<(), String> {
997    match e {
998        Expr::Const(_) => Ok(()),
999        Expr::Var(i) => {
1000            if *i < n {
1001                Ok(())
1002            } else {
1003                Err(format!("references Var({i}) but n = {n}"))
1004            }
1005        }
1006        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1007            validate_expr(a, n, seen)?;
1008            validate_expr(b, n, seen)
1009        }
1010        Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
1011        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1012            for a in args {
1013                validate_expr(a, n, seen)?;
1014            }
1015            Ok(())
1016        }
1017        Expr::Cond { cond, then_, else_ } => {
1018            validate_expr(cond, n, seen)?;
1019            validate_expr(then_, n, seen)?;
1020            validate_expr(else_, n, seen)
1021        }
1022        Expr::Cse(body) => {
1023            if seen.insert(Arc::as_ptr(body)) {
1024                validate_expr(body, n, seen)
1025            } else {
1026                Ok(())
1027            }
1028        }
1029        Expr::Funcall { id, .. } => Err(format!(
1030            "references AMPL imported function id {id}, which this path cannot \
1031             resolve: a problem built from expressions has no F-segment \
1032             declarations to bind it to. Load such a model with read_nl or \
1033             parse_nl_text instead."
1034        )),
1035    }
1036}
1037
1038/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
1039/// into dense vectors at problem load time so callers can index by
1040/// variable / constraint number directly. Empty maps when the `.nl`
1041/// file declared no suffixes.
1042#[derive(Debug, Clone, Default)]
1043pub struct NlSuffixes {
1044    /// Variable-level integer suffixes (kind = 0). Each vector has
1045    /// length `n_full` (problem variables).
1046    pub var_int: BTreeMap<String, Vec<Index>>,
1047    /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
1048    pub con_int: BTreeMap<String, Vec<Index>>,
1049    /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
1050    pub obj_int: BTreeMap<String, Vec<Index>>,
1051    /// Problem-level integer suffixes (kind = 3). Single value per name.
1052    pub problem_int: BTreeMap<String, Index>,
1053    /// Variable-level real suffixes (kind = 4). Length `n_full`.
1054    pub var_real: BTreeMap<String, Vec<Number>>,
1055    /// Constraint-level real suffixes (kind = 5). Length `m_full`.
1056    pub con_real: BTreeMap<String, Vec<Number>>,
1057    /// Objective-level real suffixes (kind = 6). Length `num_obj`.
1058    pub obj_real: BTreeMap<String, Vec<Number>>,
1059    /// Problem-level real suffixes (kind = 7). Single value per name.
1060    pub problem_real: BTreeMap<String, Number>,
1061}
1062
1063/// Parse an `.nl` file from disk.
1064///
1065/// After parsing the `.nl` body, this also looks for AMPL's optional
1066/// sibling name files — `stub.col` (variable names) and `stub.row`
1067/// (constraint names), emitted only when the modeler sets
1068/// `option auxfiles rc;`. When present and well-formed they populate
1069/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
1070/// malformed the names stay empty and every downstream consumer falls
1071/// back to indices. Names are a diagnostic nicety, never load-blocking
1072/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1073pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
1074    // AMPL invokes a solver with an extensionless *stub* — e.g.
1075    // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
1076    // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
1077    // path as given is missing but appending `.nl` names an existing file,
1078    // resolve to that. This only ever *adds* a fallback: an existing path
1079    // is read verbatim, so nothing changes for callers that already pass a
1080    // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
1081    let resolved = if path.exists() {
1082        path.to_path_buf()
1083    } else {
1084        let with_nl = append_extension(path, "nl");
1085        if with_nl.exists() {
1086            with_nl
1087        } else {
1088            path.to_path_buf()
1089        }
1090    };
1091    let txt = std::fs::read_to_string(&resolved)
1092        .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
1093    // By value: a recognized body keeps a byte range into this text rather
1094    // than a tree, so it is moved into the problem instead of copied.
1095    let mut prob = parse_nl_string(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())?;
1096    prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
1097    prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
1098    Ok(prob)
1099}
1100
1101/// Append `.ext` to `path`'s full file name (AMPL stub convention:
1102/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
1103/// which would *replace* an existing extension. A stub that itself
1104/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
1105/// way AMPL names it.
1106fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
1107    let mut name = path.as_os_str().to_os_string();
1108    name.push(".");
1109    name.push(ext);
1110    std::path::PathBuf::from(name)
1111}
1112
1113/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
1114/// order. Returns the first `expected` names, or an empty vector when the
1115/// file is missing, unreadable, or has fewer than `expected` lines.
1116///
1117/// Returning empty (rather than erroring) on any mismatch is deliberate:
1118/// names are an optional diagnostic aid, so a missing or truncated file
1119/// must never block a solve. The `.take(expected)` also drops AMPL's
1120/// convention of appending the objective name after the constraint names
1121/// in `.row`, keeping the result aligned 1:1 with `g`.
1122fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
1123    let Ok(txt) = std::fs::read_to_string(path) else {
1124        return Vec::new();
1125    };
1126    let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
1127    if names.len() == expected {
1128        names
1129    } else {
1130        Vec::new()
1131    }
1132}
1133
1134/// The value of a constraint's nonlinear part when that part is a
1135/// constant, else `None`. Drives the constant-row-body fold in
1136/// [`parse_nl_text`].
1137///
1138/// "Constant" is decided by *evaluation*, not by syntax: a literal
1139/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
1140/// and is folded too.
1141///
1142/// Declines in two cases, both of which would make the fold unsound:
1143/// * The expression calls an AMPL imported function. Its value depends on
1144///   a shared library resolved much later (`nl_external::ExternalResolver`),
1145///   so it is not a parse-time constant even with constant arguments — and
1146///   [`eval_expr`] panics on `Funcall` rather than guess.
1147/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
1148///   a NaN or infinity into a bound would corrupt a row that is merely
1149///   infeasible; leaving the expression in place keeps it a solver-time
1150///   fact.
1151fn row_constant_value(e: &Expr) -> Option<Number> {
1152    // The identity zero the parser preallocates for every untouched row is
1153    // by far the most common input; settle it without walking anything.
1154    if let Expr::Const(c) = e {
1155        return c.is_finite().then_some(*c);
1156    }
1157    let mut vars: BTreeSet<usize> = BTreeSet::new();
1158    collect_vars(e, &mut vars);
1159    if !vars.is_empty() {
1160        return None;
1161    }
1162    let mut funcs: BTreeSet<usize> = BTreeSet::new();
1163    crate::nl_external::collect_funcall_ids(e, &mut funcs);
1164    if !funcs.is_empty() {
1165        return None;
1166    }
1167    // Variable-free, so no `Expr::Var` can index into the (empty) point.
1168    let v = eval_expr(e, &[]);
1169    v.is_finite().then_some(v)
1170}
1171
1172/// Parse `.nl` text content. Public so tests can use string literals.
1173///
1174/// Degree-2 bodies are recognized as the token stream is read, so their
1175/// `Expr` trees are never built (gh #588, Q5); `POUNCE_DBG_NO_QUAD=1`
1176/// turns that off and restores the pre-Q5 parse exactly, which is what the
1177/// A/B reference and gh #540's guard test need. See
1178/// [`parse_nl_text_with_quadratic`] for the same switch as a parameter.
1179pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
1180    parse_nl_text_with_quadratic(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
1181}
1182
1183/// [`parse_nl_text`] with parse-time quadratic recognition explicitly on
1184/// or off.
1185///
1186/// The env var the plain entry point reads is process-global, which is
1187/// exactly wrong for a differential test that has to parse the *same* text
1188/// both ways in one process and compare the results — so the knob is a
1189/// parameter here, mirroring [`NlTnlp::try_new_with_quadratic`].
1190pub fn parse_nl_text_with_quadratic(txt: &str, use_quadratic: bool) -> Result<NlProblem, String> {
1191    parse_nl_string(txt.to_string(), use_quadratic)
1192}
1193
1194/// [`parse_nl_text_with_quadratic`], taking the text by value.
1195///
1196/// A recognized body keeps a byte range into the source rather than a tree,
1197/// so the source outlives the parse. Taking ownership means the file
1198/// [`read_nl_file`] already read is *moved* into the problem instead of
1199/// copied: the text is resident during parsing either way, so this costs
1200/// nothing at the peak the phase is about.
1201pub fn parse_nl_string(txt: String, use_quadratic: bool) -> Result<NlProblem, String> {
1202    let src = Arc::new(txt);
1203    let mut p = Parser::new(&src, use_quadratic);
1204    p.parse_header()?;
1205    let n = p.n;
1206    let m = p.m;
1207    let num_obj = p.num_obj;
1208
1209    let mut con_nonlinear: Vec<NlBody> = (0..m).map(|_| NlBody::Tree(Expr::Const(0.0))).collect();
1210    let mut obj_nonlinear = NlBody::Tree(Expr::Const(0.0));
1211    let mut minimize = true;
1212    let mut obj_linear: Vec<(usize, Number)> = Vec::new();
1213    let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1214    let mut x_l = vec![-1e19; n];
1215    let mut x_u = vec![1e19; n];
1216    let mut g_l = vec![-1e19; m];
1217    let mut g_u = vec![1e19; m];
1218    let mut x0 = vec![0.0; n];
1219    let mut lambda0 = vec![0.0; m];
1220    let mut suffixes = NlSuffixes::default();
1221    let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
1222    // Segment presence, for the truncation check after the loop (gh#785).
1223    let mut saw_r = false;
1224    let mut saw_b = false;
1225
1226    while let Some(line) = p.peek_segment_line() {
1227        let tag = line
1228            .trim_start()
1229            .chars()
1230            .next()
1231            .ok_or("unexpected blank segment header")?;
1232        match tag {
1233            'C' => {
1234                let (_hdr, rest) = p.eat_segment_header()?;
1235                let _ = rest;
1236                let idx = parse_segment_index(_hdr, 'C')?;
1237                if idx >= m {
1238                    return Err(format!("C{idx} out of range; m={m}"));
1239                }
1240                con_nonlinear[idx] = p.parse_body()?;
1241            }
1242            'O' => {
1243                let (hdr, _rest) = p.eat_segment_header()?;
1244                let parts: Vec<&str> = hdr.split_whitespace().collect();
1245                if parts.len() < 2 {
1246                    return Err(format!("malformed O-segment header: {hdr}"));
1247                }
1248                let idx = parse_segment_index(parts[0], 'O')?;
1249                let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
1250                if idx == 0 {
1251                    minimize = kind == 0;
1252                    obj_nonlinear = p.parse_body()?;
1253                } else {
1254                    // Extra objectives are read but ignored.
1255                    let _ = p.parse_expr()?;
1256                }
1257            }
1258            'r' => {
1259                p.eat_segment_header()?;
1260                saw_r = true;
1261                for i in 0..m {
1262                    let line = p.next_data_line()?;
1263                    let (lo, hi) = parse_bound_line(line)?;
1264                    g_l[i] = lo;
1265                    g_u[i] = hi;
1266                }
1267            }
1268            'b' => {
1269                p.eat_segment_header()?;
1270                saw_b = true;
1271                for i in 0..n {
1272                    let line = p.next_data_line()?;
1273                    let (lo, hi) = parse_bound_line(line)?;
1274                    x_l[i] = lo;
1275                    x_u[i] = hi;
1276                }
1277            }
1278            'k' => {
1279                // Column counts in the Jacobian; we don't need their
1280                // values for evaluation (the J segments give explicit
1281                // lists), but we must consume exactly as many data lines
1282                // as follow or the segment stream desyncs. The `.nl`
1283                // format writes that line count in the header itself
1284                // (`k<count>`), and the standard value is `n-1`. Read the
1285                // declared count rather than assuming it: a file with a
1286                // nonstandard count would otherwise leave us reading the
1287                // wrong number of lines, swallowing a later segment header
1288                // (or stopping short) and failing with a confusing,
1289                // far-removed error. Validate against the expected `n-1`
1290                // so a mismatch surfaces here, clearly, at its source.
1291                let (hdr, _) = p.eat_segment_header()?;
1292                let declared = parse_segment_index(hdr, 'k')?;
1293                let expected = if n == 0 { 0 } else { n - 1 };
1294                if declared != expected {
1295                    return Err(format!(
1296                        "k-segment declares {declared} column-count lines but \
1297                         the standard count for n={n} variables is {expected}"
1298                    ));
1299                }
1300                for _ in 0..declared {
1301                    p.next_data_line()?;
1302                }
1303            }
1304            'J' => {
1305                let (hdr, _) = p.eat_segment_header()?;
1306                let parts: Vec<&str> = hdr.split_whitespace().collect();
1307                if parts.len() < 2 {
1308                    return Err(format!("malformed J-segment header: {hdr}"));
1309                }
1310                let row = parse_segment_index(parts[0], 'J')?;
1311                let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
1312                if row >= m {
1313                    return Err(format!("J{row} out of range"));
1314                }
1315                for _ in 0..nz {
1316                    let line = p.next_data_line()?;
1317                    let (var, coef) = parse_var_coef(line)?;
1318                    // Validate the column index here: an out-of-range `var`
1319                    // would otherwise be stored and panic as a slice OOB
1320                    // (`x[var]`) during constraint evaluation. Mirror the
1321                    // clean parse error used for the row index above.
1322                    if var >= n {
1323                        return Err(format!(
1324                            "J{row} entry variable index {var} out of range (n={n})"
1325                        ));
1326                    }
1327                    con_linear[row].push((var, coef));
1328                }
1329            }
1330            'G' => {
1331                let (hdr, _) = p.eat_segment_header()?;
1332                let parts: Vec<&str> = hdr.split_whitespace().collect();
1333                if parts.len() < 2 {
1334                    return Err(format!("malformed G-segment header: {hdr}"));
1335                }
1336                let idx = parse_segment_index(parts[0], 'G')?;
1337                let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
1338                let mut acc = Vec::with_capacity(nz);
1339                for _ in 0..nz {
1340                    let line = p.next_data_line()?;
1341                    let (var, coef) = parse_var_coef(line)?;
1342                    // Same as J: reject an out-of-range gradient column index
1343                    // up front rather than letting it panic on `x[var]` later.
1344                    if var >= n {
1345                        return Err(format!(
1346                            "G{idx} entry variable index {var} out of range (n={n})"
1347                        ));
1348                    }
1349                    acc.push((var, coef));
1350                }
1351                if idx == 0 {
1352                    obj_linear = acc;
1353                }
1354            }
1355            'x' => {
1356                let (hdr, _) = p.eat_segment_header()?;
1357                let parts: Vec<&str> = hdr.split_whitespace().collect();
1358                let nx: usize = parts
1359                    .first()
1360                    .and_then(|s| s.trim_start_matches('x').parse().ok())
1361                    .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
1362                for _ in 0..nx {
1363                    let line = p.next_data_line()?;
1364                    let (idx, val) = parse_var_coef(line)?;
1365                    // Reject out-of-range indices as a parse error, matching
1366                    // J/G strictness, rather than silently dropping the entry
1367                    // (which hides a corrupt initial-primal segment).
1368                    if idx >= n {
1369                        return Err(format!(
1370                            "x-segment variable index {idx} out of range (n={n})"
1371                        ));
1372                    }
1373                    x0[idx] = val;
1374                }
1375            }
1376            'd' => {
1377                let (hdr, _) = p.eat_segment_header()?;
1378                let parts: Vec<&str> = hdr.split_whitespace().collect();
1379                let nd: usize = parts
1380                    .first()
1381                    .and_then(|s| s.trim_start_matches('d').parse().ok())
1382                    .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
1383                for _ in 0..nd {
1384                    let line = p.next_data_line()?;
1385                    let (idx, val) = parse_var_coef(line)?;
1386                    // Reject out-of-range indices as a parse error, matching
1387                    // J/G strictness, rather than silently dropping the entry
1388                    // (which hides a corrupt initial-dual segment).
1389                    if idx >= m {
1390                        return Err(format!(
1391                            "d-segment constraint index {idx} out of range (m={m})"
1392                        ));
1393                    }
1394                    lambda0[idx] = val;
1395                }
1396            }
1397            'V' => p.parse_v_segment()?,
1398            'S' => {
1399                parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
1400            }
1401            'F' => {
1402                // AMPL imported (external) function declaration:
1403                // `F<k> <type> <nargs> <name>`.
1404                let (hdr, _rest) = p.eat_segment_header()?;
1405                let parts: Vec<&str> = hdr.split_whitespace().collect();
1406                if parts.is_empty() {
1407                    return Err(format!("malformed F-segment header: '{hdr}'"));
1408                }
1409                let id = parse_segment_index(parts[0], 'F')?;
1410                let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1411                let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1412                let name = parts.get(3).copied().unwrap_or("").to_string();
1413                imported_funcs.push(ImportedFunc {
1414                    id,
1415                    kind,
1416                    nargs,
1417                    name,
1418                });
1419            }
1420            other => return Err(format!("unknown .nl segment tag '{other}'")),
1421        }
1422    }
1423
1424    // A `.nl` file that ends early is not a smaller model — it is a corrupt
1425    // one, and every segment the truncation ate is a piece of the problem
1426    // that silently reverts to a default: a dropped `r` leaves every row at
1427    // ±1e19 (i.e. unconstrained), a dropped `b` leaves every variable free,
1428    // a dropped `J` leaves every row's linear part empty. The segment loop
1429    // cannot tell those defaults from a legitimately absent segment, so the
1430    // truncated model solves, and reports `SolveSucceeded` with a
1431    // confidently wrong objective — which is strictly worse than the parse
1432    // error every other malformed input already gets (gh#785). The header
1433    // declares enough to tell the two apart; check it here, once, against
1434    // what the segments actually delivered.
1435    //
1436    // `r` and `b` are unconditional in the format: AMPL writes both whenever
1437    // the model has rows / columns at all, free rows and free variables
1438    // included — those get the `3` ("no bounds") code, not an omitted line.
1439    if m > 0 && !saw_r {
1440        return Err(format!(
1441            "missing `r` (constraint-bounds) segment for a model declaring {m} \
1442             constraint(s): the .nl file is truncated or corrupt"
1443        ));
1444    }
1445    if n > 0 && !saw_b {
1446        return Err(format!(
1447            "missing `b` (variable-bounds) segment for a model declaring {n} \
1448             variable(s): the .nl file is truncated or corrupt"
1449        ));
1450    }
1451    // The `J` segments must deliver exactly the Jacobian nonzero count the
1452    // header declares — `nzc` is the sum of their lengths by construction,
1453    // nonlinear-only columns included (they are written with a zero
1454    // coefficient, not omitted). This is the check that catches a truncation
1455    // landing *after* `r` and `b`, where the bounds are all present and only
1456    // the coefficients are gone.
1457    if let Some(declared) = p.declared_jac_nnz {
1458        let parsed: usize = con_linear.iter().map(Vec::len).sum();
1459        if parsed != declared {
1460            return Err(format!(
1461                "header declares {declared} Jacobian nonzero(s) but the J \
1462                 segments supply {parsed}: the .nl file is truncated or corrupt"
1463            ));
1464        }
1465    }
1466
1467    // Normalize constant row bodies into the row bounds, so that
1468    // "the nonlinear part is the identity zero" and "this row's body is
1469    // Σaⱼxⱼ" mean the same thing for every downstream consumer.
1470    //
1471    // A `C<i>` segment holding a variable-free expression (`n3.0`, or
1472    // anything that evaluates to a constant such as `o0 n1 n2`) is an
1473    // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
1474    // which every consumer reads as "nonlinear": the linearity predicate
1475    // (`get_constraints_linearity`), which is what makes presolve's
1476    // linear-equality reduction decline the row; the CLI problem
1477    // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
1478    // polynomial walk absorbs a bare literal but not a constant it has to
1479    // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
1480    // NLP and never reached the convex path); and the FBBT translator.
1481    // Folding here fixes all of them at once, with no per-consumer audit.
1482    //
1483    // The shift is exact and invisible from outside: the body drops by `c`
1484    // and each bound drops by `c` with it, so the feasible set, the active
1485    // set, and the duals are unchanged (`gh #492`). This is the same
1486    // normalization `qp_extract::analyze_quadratic_full` already performs
1487    // ad hoc via `const_shift`, promoted to the parse boundary.
1488    for i in 0..m {
1489        // A recognized body is degree 2, so it is not a constant row and
1490        // this fold has nothing to do with it.
1491        let Some(tree) = con_nonlinear[i].tree() else {
1492            continue;
1493        };
1494        let Some(c) = row_constant_value(tree) else {
1495            continue;
1496        };
1497        // Presence is directional (gh #401): shifting an *absent* bound
1498        // would turn the ±1e19 sentinel into a real bound for `c < 0`
1499        // (lower) or `c > 0` (upper), inventing a constraint. Leave the
1500        // sentinels alone.
1501        if lower_bound_present(g_l[i]) {
1502            g_l[i] -= c;
1503        }
1504        if upper_bound_present(g_u[i]) {
1505            g_u[i] -= c;
1506        }
1507        con_nonlinear[i] = NlBody::Tree(Expr::Const(0.0));
1508    }
1509
1510    // The source is kept only when something in it has no tree of its own.
1511    // A model with nothing recognized is byte-for-byte the pre-Q5 problem,
1512    // extra field included.
1513    let any_recognized =
1514        obj_nonlinear.quad().is_some() || con_nonlinear.iter().any(|b| b.quad().is_some());
1515    let kept_src = any_recognized.then(|| Arc::clone(&src));
1516
1517    Ok(NlProblem {
1518        n,
1519        m,
1520        num_obj,
1521        minimize,
1522        obj_nonlinear,
1523        obj_linear,
1524        obj_constant: 0.0,
1525        con_nonlinear,
1526        con_linear,
1527        x_l,
1528        x_u,
1529        g_l,
1530        g_u,
1531        x0,
1532        lambda0,
1533        suffixes,
1534        ampl_options: p.ampl_options.clone(),
1535        nl_counts: p.nl_counts,
1536        imported_funcs,
1537        // `.nl` text carries no names; `read_nl_file` fills these from the
1538        // sibling `.col`/`.row` files when present.
1539        var_names: Vec::new(),
1540        con_names: Vec::new(),
1541        src: kept_src,
1542        cse_bodies: p.cses.clone(),
1543    })
1544}
1545
1546/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
1547/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
1548///
1549/// ```text
1550/// S<kind> <nentries> <suffix_name>
1551/// <idx> <value>      ... nentries lines
1552/// ```
1553///
1554/// `<kind>` is a 3-bit encoding:
1555/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
1556///   2 = objectives, 3 = problem-level.
1557/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
1558///
1559/// Sparse entries scatter into a freshly-allocated dense vector (zero
1560/// default), sized for the target dimension. Problem-level suffixes
1561/// (kind = 3 / 7) carry a single value.
1562fn parse_suffix_segment(
1563    p: &mut Parser,
1564    n: usize,
1565    m: usize,
1566    num_obj: usize,
1567    out: &mut NlSuffixes,
1568) -> Result<(), String> {
1569    let (hdr, _) = p.eat_segment_header()?;
1570    let parts: Vec<&str> = hdr.split_whitespace().collect();
1571    if parts.len() < 3 {
1572        return Err(format!(
1573            "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
1574        ));
1575    }
1576    let kind_str = parts[0].trim_start_matches('S');
1577    let kind: u32 = kind_str
1578        .parse()
1579        .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
1580    let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
1581    let name = parts[2].to_string();
1582
1583    let is_real = (kind & 0x4) != 0;
1584    let target = kind & 0x3;
1585    let target_dim = match target {
1586        0 => n,
1587        1 => m,
1588        2 => num_obj,
1589        3 => 0, // problem-level — entries are single-valued (idx=0)
1590        _ => unreachable!("kind & 0x3 is in 0..=3"),
1591    };
1592
1593    // Pre-allocate dense buffers (default zero). Problem-level kinds
1594    // (3 / 7) hold a single scalar — we still read the (idx, value)
1595    // pairs but only the value field is meaningful.
1596    let mut int_buf: Vec<Index> = if !is_real && target != 3 {
1597        vec![0; target_dim]
1598    } else {
1599        Vec::new()
1600    };
1601    let mut real_buf: Vec<Number> = if is_real && target != 3 {
1602        vec![0.0; target_dim]
1603    } else {
1604        Vec::new()
1605    };
1606    let mut problem_int: Index = 0;
1607    let mut problem_real: Number = 0.0;
1608
1609    for _ in 0..nentries {
1610        let line = p.next_data_line()?;
1611        let parts: Vec<&str> = line.split_whitespace().collect();
1612        if parts.len() < 2 {
1613            return Err(format!(
1614                "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
1615            ));
1616        }
1617        let idx: usize = parts[0]
1618            .parse()
1619            .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
1620        if target != 3 && idx >= target_dim {
1621            return Err(format!(
1622                "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
1623            ));
1624        }
1625        if is_real {
1626            let v: Number = parts[1]
1627                .parse()
1628                .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
1629            if target == 3 {
1630                problem_real = v;
1631            } else {
1632                real_buf[idx] = v;
1633            }
1634        } else {
1635            let v: Index = parts[1]
1636                .parse()
1637                .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
1638            if target == 3 {
1639                problem_int = v;
1640            } else {
1641                int_buf[idx] = v;
1642            }
1643        }
1644    }
1645
1646    match (target, is_real) {
1647        (0, false) => {
1648            out.var_int.insert(name, int_buf);
1649        }
1650        (1, false) => {
1651            out.con_int.insert(name, int_buf);
1652        }
1653        (2, false) => {
1654            out.obj_int.insert(name, int_buf);
1655        }
1656        (3, false) => {
1657            out.problem_int.insert(name, problem_int);
1658        }
1659        (0, true) => {
1660            out.var_real.insert(name, real_buf);
1661        }
1662        (1, true) => {
1663            out.con_real.insert(name, real_buf);
1664        }
1665        (2, true) => {
1666            out.obj_real.insert(name, real_buf);
1667        }
1668        (3, true) => {
1669            out.problem_real.insert(name, problem_real);
1670        }
1671        _ => unreachable!(),
1672    }
1673    Ok(())
1674}
1675
1676fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1677    let trimmed = s.trim_start_matches(tag);
1678    trimmed
1679        .parse()
1680        .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1681}
1682
1683// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1684// `G` / `x` / `d` data line, so between them they see every Jacobian and
1685// gradient nonzero in the file. Both walk the whitespace iterator
1686// directly instead of collecting a `Vec<&str>` first — that collect was
1687// a heap allocation per line on top of the one the reader used to make
1688// handing the line over.
1689/// Refuse a non-finite number read out of a `.nl` file (gh #847).
1690///
1691/// `str::parse::<f64>()` accepts `inf`, `-inf` and `nan`, and it also *returns*
1692/// `inf` for any literal that overflows the type — `1e400` is a plausible thing
1693/// for a model generator to write. Nothing downstream treats such a value as an
1694/// error, and in one place it is actively misread: `lower_bound_present` /
1695/// `upper_bound_present` are `is_finite() && ...`, so a non-finite bound is
1696/// indistinguishable from a bound that was never declared, and is silently
1697/// dropped. On a model that a lower bound of `1e300` makes infeasible, the same
1698/// bound written `1e400` returned `EXIT: Optimal Solution Found.` with exit code
1699/// 0. A `nan` is worse: it propagates into the answer, and the solve reports
1700/// `Objective: nan` under `Solve_Succeeded`.
1701///
1702/// Ipopt refuses this input ("Invalid number"), POUNCE's own NLP arm refuses it,
1703/// and `pounce.solve_qp` refuses a non-finite bound with a bespoke `ValueError`.
1704/// There is no reading on which `Optimal` is the intended answer, so the reader
1705/// refuses it too.
1706fn finite_or_err(what: &str, v: Number) -> Result<Number, String> {
1707    if v.is_finite() {
1708        Ok(v)
1709    } else {
1710        Err(format!(
1711            "invalid number: {what} is {v}, which is not finite"
1712        ))
1713    }
1714}
1715
1716/// The same screen for a *bound* slot, where one non-finite value has an
1717/// unambiguous meaning and is normalized instead of refused.
1718///
1719/// `.nl` states "no bound" with a bound **kind** (1 = upper only, 2 = lower
1720/// only, 3 = free), so a non-finite number in a bound slot is a corrupt value
1721/// rather than a notation — with one exception per side. `-inf` in a *lower*
1722/// slot and `+inf` in an *upper* slot say precisely what the `±1e19` sentinel
1723/// says, and a writer that emits them means it, so they map to the sentinel.
1724///
1725/// Everything else is refused, and the asymmetry is the whole point: `+inf` as
1726/// a *lower* bound is the gh #847 case. It is not "unbounded below" — it is an
1727/// empty box, and reading it as "absent" is what turned an infeasible model
1728/// into an `Optimal` one. `NaN` is refused on either side, having no meaning at
1729/// all.
1730fn finite_bound_or_err(what: &str, v: Number, lower: bool) -> Result<Number, String> {
1731    if v.is_finite() {
1732        return Ok(v);
1733    }
1734    if lower && v == Number::NEG_INFINITY {
1735        return Ok(-1e19);
1736    }
1737    if !lower && v == Number::INFINITY {
1738        return Ok(1e19);
1739    }
1740    Err(format!(
1741        "invalid number: {what} is {v}, which is not finite (a `.nl` file \
1742         states an absent bound with a bound kind of 1, 2 or 3, not with a \
1743         non-finite value)"
1744    ))
1745}
1746
1747fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1748    let mut parts = line.split_whitespace();
1749    let kind: i32 = parts
1750        .next()
1751        .ok_or("empty bound line")?
1752        .parse()
1753        .map_err(|e| format!("bound kind: {e}"))?;
1754    let lo;
1755    let hi;
1756    match kind {
1757        0 => {
1758            // 0  lo  hi
1759            let (l, h) = (parts.next(), parts.next());
1760            let (Some(l), Some(h)) = (l, h) else {
1761                return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1762            };
1763            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1764            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1765        }
1766        1 => {
1767            // 1  hi
1768            let Some(h) = parts.next() else {
1769                return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1770            };
1771            lo = -1e19;
1772            hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1773        }
1774        2 => {
1775            // 2  lo
1776            let Some(l) = parts.next() else {
1777                return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1778            };
1779            lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1780            hi = 1e19;
1781        }
1782        3 => {
1783            // 3  (free)
1784            lo = -1e19;
1785            hi = 1e19;
1786        }
1787        4 => {
1788            // 4  eq
1789            let Some(v) = parts.next() else {
1790                return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1791            };
1792            // An equality has no "absent" side, so neither infinity is a
1793            // notation here and both are refused.
1794            let v: Number = finite_or_err("eq bound", v.parse().map_err(|e| format!("eq: {e}"))?)?;
1795            lo = v;
1796            hi = v;
1797        }
1798        5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1799        other => return Err(format!("unknown bound kind {other}")),
1800    }
1801    Ok((lo, hi))
1802}
1803
1804fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1805    let mut parts = line.split_whitespace();
1806    let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1807        return Err(format!("malformed var/coef line: '{line}'"));
1808    };
1809    let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1810    let c: Number = finite_or_err("coefficient", c.parse().map_err(|e| format!("coef: {e}"))?)?;
1811    Ok((v, c))
1812}
1813
1814/// Build an [`NlCounts`] from `.nl` header lines 3 (`nlc nlo`) and 5
1815/// (`nlvc nlvo nlvb`).
1816///
1817/// Returns `None` unless both lines carry the full complement of
1818/// non-negative integers, so a truncated or non-conforming header reads as
1819/// "unknown" rather than as zeros — "no nonlinear variables" is a claim, and
1820/// a header that failed to parse has not made it.
1821fn parse_nl_counts(line3: &str, line5: &str) -> Option<NlCounts> {
1822    let nums = |line: &str, want: usize| -> Option<Vec<usize>> {
1823        let v: Vec<usize> = line
1824            .split_whitespace()
1825            .take(want)
1826            .map(str::parse)
1827            .collect::<Result<_, _>>()
1828            .ok()?;
1829        (v.len() == want).then_some(v)
1830    };
1831    let cons_objs = nums(line3, 2)?;
1832    let vars = nums(line5, 3)?;
1833    Some(NlCounts {
1834        nl_cons: cons_objs[0],
1835        nl_objs: cons_objs[1],
1836        nl_vars_cons: vars[0],
1837        nl_vars_objs: vars[1],
1838        nl_vars_both: vars[2],
1839    })
1840}
1841
1842struct Parser<'a> {
1843    lines: Vec<&'a str>,
1844    /// Start address and length of the source text. A recognized body
1845    /// records the byte *range* it consumed so it can be re-parsed later
1846    /// (see [`QuadBody::src`]), and every line borrows from this buffer, so
1847    /// one subtraction per line recovers its offset.
1848    ///
1849    /// Deliberately not a per-line offset table: on a 119 MB generated
1850    /// `qcqp500-3c` the file is ~17 M lines, and a `Vec<usize>` beside the
1851    /// existing `Vec<&str>` would add 136 MB to the peak this phase exists
1852    /// to reduce.
1853    txt_base: usize,
1854    txt_len: usize,
1855    pos: usize,
1856    n: usize,
1857    m: usize,
1858    num_obj: usize,
1859    /// Number of AMPL imported (external) functions declared in the header.
1860    n_funcs: usize,
1861    /// Header lines 3 and 5, when both parsed. See [`NlCounts`].
1862    nl_counts: Option<NlCounts>,
1863    /// `nzc` from header line 8: the number of Jacobian nonzeros the file
1864    /// *declares*. [`parse_nl_string`] cross-checks it against the number the
1865    /// `J` segments actually deliver, which is how a file truncated before
1866    /// them is told from a model that genuinely has none (gh#785). `None`
1867    /// when the header does not carry it in the documented shape.
1868    declared_jac_nnz: Option<usize>,
1869    ampl_options: Vec<i64>,
1870    /// Common subexpressions (`V` segments). Index in this vec is the
1871    /// CSE-local index, i.e. the global `.nl` index minus `n`.
1872    cses: Vec<Arc<Expr>>,
1873    /// Recognize degree-2 bodies from the token stream instead of building
1874    /// their trees (gh #588, Q5). Off restores the pre-Q5 parse exactly.
1875    quad_enabled: bool,
1876    /// Per-CSE answers the streaming recognizer needs about a `V` body it
1877    /// may be handed a reference to, all keyed by CSE-local index: the
1878    /// body's own degree-≤2 form, whether it is legal on a sum spine,
1879    /// whether it is legal *inside* a monomial, and its variable support.
1880    ///
1881    /// These are computed from the built `V` body with the same functions
1882    /// `NlTnlp` would apply to a whole row, so a reference costs a lookup
1883    /// and cannot drift from what the tree walk would have said. `V` bodies
1884    /// keep their trees regardless — they are shared, so the memory is
1885    /// amortized over every reference, and dropping them would mean
1886    /// rebuilding them for the rows that are *not* recognized.
1887    cse_quad: Vec<Option<Quad2>>,
1888    cse_sum_ok: Vec<bool>,
1889    cse_mono_ok: Vec<bool>,
1890    cse_vars: Vec<Vec<u32>>,
1891    cse_depth: Vec<u32>,
1892}
1893
1894/// One pending operator on the streaming recognizer's stack.
1895struct QFrame {
1896    op: QOp,
1897    /// Operands still to be read.
1898    remaining: usize,
1899    /// Read this frame's operands in monomial mode — no `+`/`-` may appear
1900    /// below it. See [`crate::nl_quadratic::is_expanded_quadratic`].
1901    mono: bool,
1902}
1903
1904/// The operators the streaming recognizer accepts. One variant per shape
1905/// that [`crate::nl_quadratic::recognize_expr`] handles; everything else
1906/// makes it bail.
1907enum QOp {
1908    Neg,
1909    Add,
1910    Sub,
1911    Mul,
1912    Div,
1913    /// `o5`/`o81`/`o83`: base and exponent both read.
1914    Pow,
1915    /// `o82`: square, exponent implicit.
1916    Square,
1917    Sum(usize),
1918}
1919
1920impl<'a> Parser<'a> {
1921    fn new(txt: &'a str, quad_enabled: bool) -> Self {
1922        let lines: Vec<&str> = txt.lines().collect();
1923        Self {
1924            lines,
1925            txt_base: txt.as_ptr() as usize,
1926            txt_len: txt.len(),
1927            pos: 0,
1928            n: 0,
1929            m: 0,
1930            num_obj: 0,
1931            n_funcs: 0,
1932            nl_counts: None,
1933            declared_jac_nnz: None,
1934            ampl_options: Vec::new(),
1935            cses: Vec::new(),
1936            quad_enabled,
1937            cse_quad: Vec::new(),
1938            cse_sum_ok: Vec::new(),
1939            cse_mono_ok: Vec::new(),
1940            cse_vars: Vec::new(),
1941            cse_depth: Vec::new(),
1942        }
1943    }
1944
1945    /// Byte offset in the source text of the start of line `line`, or the
1946    /// end of the text when the cursor has run off it.
1947    ///
1948    /// Pointer arithmetic against the same allocation, never a deref: every
1949    /// line in `lines` borrows from the source buffer, so the difference is
1950    /// its offset.
1951    fn byte_at(&self, line: usize) -> usize {
1952        match self.lines.get(line) {
1953            Some(l) => l.as_ptr() as usize - self.txt_base,
1954            None => self.txt_len,
1955        }
1956    }
1957
1958    fn next_line(&mut self) -> Option<&'a str> {
1959        while self.pos < self.lines.len() {
1960            let l = self.lines[self.pos];
1961            self.pos += 1;
1962            // Strip comment after '#' for header / data lines (but
1963            // leave the segment-tag tokens untouched — they are the
1964            // first token on the line).
1965            let trimmed = strip_comment(l).trim();
1966            if !trimmed.is_empty() {
1967                return Some(l);
1968            }
1969        }
1970        None
1971    }
1972
1973    /// Next non-blank line, comment stripped and trimmed.
1974    ///
1975    /// Borrows from the source text rather than allocating: the result
1976    /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
1977    /// outlives the `&mut self` this took. A large `.nl` is mostly data
1978    /// lines — 620k of them for a 20k-variable model — and returning an
1979    /// owned `String` put one heap allocation on every single one.
1980    fn next_data_line(&mut self) -> Result<&'a str, String> {
1981        while self.pos < self.lines.len() {
1982            let l = self.lines[self.pos];
1983            self.pos += 1;
1984            let trimmed = strip_comment(l).trim();
1985            if !trimmed.is_empty() {
1986                return Ok(trimmed);
1987            }
1988        }
1989        Err("unexpected end of file in data line".to_string())
1990    }
1991
1992    fn parse_header(&mut self) -> Result<(), String> {
1993        let line0 = self.next_line().ok_or("empty .nl file")?;
1994        let trimmed = strip_comment(line0).trim();
1995        let first = trimmed.chars().next().ok_or("empty header line")?;
1996        if first != 'g' {
1997            return Err(format!(
1998                "only ASCII (g-) .nl files supported; got header '{trimmed}'"
1999            ));
2000        }
2001        // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
2002        // `g` say how many AMPL option words follow on the same line.
2003        // A solver echoes them back in the `.sol` `Options` block, so
2004        // keep them verbatim. Malformed or truncated option lists are not
2005        // fatal — the writer falls back to a generic block.
2006        let mut words = trimmed.split_whitespace();
2007        let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
2008        let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
2009        if opts.len() >= n_opts {
2010            self.ampl_options = opts[..n_opts].to_vec();
2011        }
2012
2013        // Header line 2: n_vars n_cons n_objs ranges eqns
2014        let l2 = self.next_data_line()?;
2015        let nums: Vec<&str> = l2.split_whitespace().collect();
2016        if nums.len() < 3 {
2017            return Err(format!("malformed line 2: '{l2}'"));
2018        }
2019        self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
2020        self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
2021        self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
2022
2023        // Header line 3: `nlc nlo`. Line 4: the network-constraint census,
2024        // which pounce has no use for. Line 5: `nlvc nlvo nlvb`. Together
2025        // these are the model's nonlinearity census — see [`NlCounts`].
2026        //
2027        // A header that does not carry them in the documented shape leaves
2028        // `nl_counts` at `None` rather than at a guess: every consumer has a
2029        // walk-the-trees fallback, and a fabricated count is worse than an
2030        // absent one. The rest of the header stays tolerant in the same way
2031        // the `nfunc` read below is.
2032        let l3 = self.next_data_line()?;
2033        let _l4_network = self.next_data_line()?;
2034        let l5 = self.next_data_line()?;
2035        self.nl_counts = parse_nl_counts(l3, l5);
2036        // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
2037        let l6 = self.next_data_line()?;
2038        let nums5: Vec<&str> = l6.split_whitespace().collect();
2039        self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
2040        // Lines 6..10 are metadata we mostly don't need. Line 7 is the
2041        // discrete-variable census; line 8 is `nzc nzo` — the Jacobian and
2042        // objective-gradient nonzero counts, kept because `nzc` is what
2043        // catches a file truncated before its `J` segments (gh#785); lines
2044        // 9 and 10 are the maximum name lengths and the common-expression
2045        // census.
2046        let _l7_discrete = self.next_data_line()?;
2047        let l8 = self.next_data_line()?;
2048        // Tolerant like the `nfunc` read above: a header that does not
2049        // carry the count in the documented shape leaves it `None`, and
2050        // the cross-check that reads it is skipped rather than fabricated.
2051        self.declared_jac_nnz = l8.split_whitespace().next().and_then(|s| s.parse().ok());
2052        let _l9_name_lens = self.next_data_line()?;
2053        let _l10_common_exprs = self.next_data_line()?;
2054        Ok(())
2055    }
2056
2057    fn peek_segment_line(&mut self) -> Option<&'a str> {
2058        let saved = self.pos;
2059        let l = self.next_line()?;
2060        self.pos = saved;
2061        Some(l)
2062    }
2063
2064    /// Eat the next non-blank line as a segment header. Returns the
2065    /// whole header (after stripping comments) and the comment text.
2066    fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
2067        let raw = self
2068            .next_line()
2069            .ok_or_else(|| "expected segment header".to_string())?;
2070        let (hdr, comment) = split_comment(raw);
2071        Ok((hdr.trim(), comment.trim()))
2072    }
2073
2074    /// Parse one `C`/`O` body, recognizing it from the token stream when
2075    /// that is possible instead of building its tree (gh #588, Q5).
2076    ///
2077    /// The cursor is the whole mechanism. `Parser` is line-based, so a
2078    /// failed recognition costs one assignment to rewind — the same trick
2079    /// `parse_funcall_arg` already uses to route a Hollerith literal — and
2080    /// the fallback then produces exactly the tree this file has always
2081    /// produced. Nothing downstream can tell which arm ran except by
2082    /// measuring memory.
2083    ///
2084    /// Only **degree-2** forms are kept. A body that recognizes as constant
2085    /// or linear is re-parsed as a tree and stored as one: the constant-row
2086    /// fold (gh #492), the linearity contract and the classifier's LP fast
2087    /// path all key on the identity zero and on trees, they are cheap
2088    /// already, and the memory that motivates this phase is entirely in
2089    /// degree-2 rows. Refusing to touch them keeps the change confined to
2090    /// the rows it is for.
2091    fn parse_body(&mut self) -> Result<NlBody, String> {
2092        if !self.quad_enabled {
2093            return Ok(NlBody::Tree(self.parse_expr()?));
2094        }
2095        let saved = self.pos;
2096        if let Some((form, vars, depth)) = self.parse_expr_quadratic() {
2097            if !form.quadratic().is_empty() {
2098                let src = self.byte_at(saved)..self.byte_at(self.pos);
2099                return Ok(NlBody::Quad(Box::new(QuadBody {
2100                    form,
2101                    vars,
2102                    src,
2103                    depth,
2104                })));
2105            }
2106        }
2107        self.pos = saved;
2108        Ok(NlBody::Tree(self.parse_expr()?))
2109    }
2110
2111    /// Read one expression off the token stream as a degree-≤2 form,
2112    /// **without building it**, or give up.
2113    ///
2114    /// This is the same computation as
2115    /// `is_expanded_quadratic(e) && recognize_expr(e)` run on the tree these
2116    /// tokens parse to — the accuracy gate and the algebra, interleaved so
2117    /// neither needs the tree. That equivalence is the phase's correctness
2118    /// claim and is asserted directly, bit for bit, over every body of every
2119    /// `.nl` file in the repository by
2120    /// `pounce-cli/tests/quad_parse_differential.rs`.
2121    ///
2122    /// Two things it must reproduce and not merely approximate:
2123    ///
2124    /// * **The exactness rule.** `is_expanded_quadratic` admits a body only
2125    ///   when reading it as `½xᵀHx + aᵀx + c` repeats the additions the
2126    ///   writer already wrote — expanding a *factored* form cancels, which
2127    ///   is what took `airport.nl` from 16 to 300 iterations in Q4. Here
2128    ///   that rule is structural rather than a separate pass: `+`/`-` are
2129    ///   the spine, everything under a `*`, `/` or `^` is read in monomial
2130    ///   mode, and a `+` seen in monomial mode gives up.
2131    /// * **The association.** `recognize_expr` folds an `o54` sumlist from
2132    ///   the *first* operand to the last, which is also the order the AD
2133    ///   tape sums in, so this folds the same way — operands arrive here in
2134    ///   file order and are folded as they arrive. Summation order is not
2135    ///   observable on distinct monomials and is exactly observable on
2136    ///   repeated ones.
2137    ///
2138    /// On `None` the cursor is left wherever it stopped; the caller rewinds.
2139    fn parse_expr_quadratic(&mut self) -> Option<(Quad2, Vec<u32>, u32)> {
2140        let mut frames: Vec<QFrame> = Vec::new();
2141        let mut vals: Vec<Quad2> = Vec::new();
2142        let mut vars: Vec<u32> = Vec::new();
2143        // Deepest node of the tree these tokens describe. A leaf sits under
2144        // `frames.len()` operators, and a CSE reference carries its body's
2145        // depth under it — the same convention `pounce-py`'s `expr_depth`
2146        // uses, because that is who reads the answer.
2147        let mut depth: u32 = 0;
2148        let note_leaf = |frames: &[QFrame], depth: &mut u32, below: u32| {
2149            let d = u32::try_from(frames.len()).unwrap_or(u32::MAX);
2150            *depth = (*depth).max(d.saturating_add(1).saturating_add(below));
2151        };
2152
2153        loop {
2154            let mono = frames.last().is_some_and(|f| f.mono);
2155            // A `^` base must be atomic — `Const`, `Var` or a `Neg` — or the
2156            // body is a factored form dressed as a monomial. `is_monomial`
2157            // applies the same test to `Pow`'s left operand.
2158            let pow_base = matches!(
2159                frames.last(),
2160                Some(QFrame {
2161                    op: QOp::Pow,
2162                    remaining: 2,
2163                    ..
2164                }) | Some(QFrame {
2165                    op: QOp::Square,
2166                    remaining: 1,
2167                    ..
2168                })
2169            );
2170
2171            let raw = self.next_line()?;
2172            let tok = strip_comment(raw).trim();
2173            let first = tok.chars().next()?;
2174            match first {
2175                'n' => {
2176                    // A constant base is atomic; `is_monomial` accepts it.
2177                    // A non-finite literal bails out of the fast path so the
2178                    // general parser reaches it and reports the error rather
2179                    // than folding it into a quadratic (gh #847).
2180                    let v: Number = tok[1..]
2181                        .trim()
2182                        .parse()
2183                        .ok()
2184                        .filter(|v: &Number| v.is_finite())?;
2185                    note_leaf(&frames, &mut depth, 0);
2186                    vals.push(Quad2::of_constant(v));
2187                }
2188                'v' => {
2189                    let i: usize = tok[1..].trim().parse().ok()?;
2190                    if i < self.n {
2191                        note_leaf(&frames, &mut depth, 0);
2192                        vars.push(u32::try_from(i).ok()?);
2193                        vals.push(Quad2::of_var(i));
2194                    } else {
2195                        // A CSE reference lowers to `Expr::Cse`, which is
2196                        // *not* one of the shapes `is_monomial` accepts as a
2197                        // power's base.
2198                        if pow_base {
2199                            return None;
2200                        }
2201                        let local = i.checked_sub(self.n)?;
2202                        let ok = if mono {
2203                            *self.cse_mono_ok.get(local)?
2204                        } else {
2205                            *self.cse_sum_ok.get(local)?
2206                        };
2207                        if !ok {
2208                            return None;
2209                        }
2210                        let form = self.cse_quad.get(local)?.clone()?;
2211                        note_leaf(&frames, &mut depth, *self.cse_depth.get(local)?);
2212                        vars.extend_from_slice(self.cse_vars.get(local)?);
2213                        vals.push(form);
2214                    }
2215                }
2216                'o' => {
2217                    let code: i32 = tok[1..].trim().parse().ok()?;
2218                    if pow_base && code != 16 {
2219                        return None;
2220                    }
2221                    let (op, arity, child_mono) = match code {
2222                        // The sum spine. Inside a monomial there is no such
2223                        // thing, and the body is a factored form.
2224                        0 if !mono => (QOp::Add, 2, false),
2225                        1 if !mono => (QOp::Sub, 2, false),
2226                        16 => (QOp::Neg, 1, mono),
2227                        54 if !mono => {
2228                            let count_line = self.next_data_line().ok()?;
2229                            let count: usize =
2230                                count_line.split_whitespace().next()?.parse().ok()?;
2231                            (QOp::Sum(count), count, false)
2232                        }
2233                        // Monomial operators: everything below them is read
2234                        // in monomial mode.
2235                        2 => (QOp::Mul, 2, true),
2236                        3 => (QOp::Div, 2, true),
2237                        5 | 81 | 83 => (QOp::Pow, 2, true),
2238                        82 => (QOp::Square, 1, true),
2239                        // Transcendentals, comparisons, conditionals,
2240                        // min/max lists, and `+`/`-` under a monomial.
2241                        _ => return None,
2242                    };
2243                    if arity == 0 {
2244                        // An empty `o54` is the empty sum: zero.
2245                        note_leaf(&frames, &mut depth, 0);
2246                        vals.push(Quad2::default());
2247                    } else {
2248                        frames.push(QFrame {
2249                            op,
2250                            remaining: arity,
2251                            mono: child_mono,
2252                        });
2253                        continue;
2254                    }
2255                }
2256                // `f` (imported function call), `h`, and anything else.
2257                _ => return None,
2258            }
2259
2260            // A value has just been produced. Close every frame it completes.
2261            while let Some(f) = frames.last_mut() {
2262                f.remaining -= 1;
2263                if f.remaining > 0 {
2264                    break;
2265                }
2266                let f = frames.pop()?;
2267                let combined = apply_quad_op(f.op, &mut vals)?;
2268                vals.push(combined);
2269            }
2270            if frames.is_empty() {
2271                break;
2272            }
2273        }
2274
2275        if vals.len() != 1 {
2276            return None;
2277        }
2278        vars.sort_unstable();
2279        vars.dedup();
2280        Some((vals.pop()?, vars, depth))
2281    }
2282
2283    fn parse_expr(&mut self) -> Result<Expr, String> {
2284        let raw = self
2285            .next_line()
2286            .ok_or_else(|| "expected expression token".to_string())?;
2287        // Borrowed, not owned: this runs once per node of every
2288        // expression tree in the file, so an owned `String` here is one
2289        // heap allocation per tape op in the whole model.
2290        let tok = strip_comment(raw).trim();
2291        if tok.is_empty() {
2292            return Err("empty expression token".into());
2293        }
2294        let first = tok.chars().next().ok_or("empty expression token")?;
2295        match first {
2296            'n' => {
2297                let v: Number = tok[1..]
2298                    .trim()
2299                    .parse()
2300                    .map_err(|e| format!("n value: {e}"))?;
2301                // A `nan` literal in the objective body reached the answer
2302                // itself: `Objective: nan` under `Solve_Succeeded` and exit
2303                // code 0 (gh #847).
2304                Ok(Expr::Const(finite_or_err("numeric literal", v)?))
2305            }
2306            'v' => {
2307                let i: usize = tok[1..]
2308                    .trim()
2309                    .parse()
2310                    .map_err(|e| format!("v index: {e}"))?;
2311                Ok(self.var_or_cse(i)?)
2312            }
2313            'o' => {
2314                let code: i32 = tok[1..]
2315                    .trim()
2316                    .parse()
2317                    .map_err(|e| format!("opcode: {e}"))?;
2318                self.parse_opcode(code)
2319            }
2320            'f' => {
2321                // AMPL imported (external) function call: `f<id> <nargs>`
2322                // followed by nargs child expressions (or string literals).
2323                let rest = &tok[1..];
2324                let mut parts = rest.split_whitespace();
2325                let id_str = parts
2326                    .next()
2327                    .ok_or_else(|| format!("missing function id in '{tok}'"))?;
2328                let nargs_str = parts
2329                    .next()
2330                    .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
2331                let id: usize = id_str
2332                    .parse()
2333                    .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
2334                let nargs: usize = nargs_str
2335                    .parse()
2336                    .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
2337                let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
2338                for _ in 0..nargs {
2339                    args.push(self.parse_funcall_arg()?);
2340                }
2341                Ok(Expr::Funcall { id, args })
2342            }
2343            't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
2344            other => Err(format!(
2345                "unexpected expression token start '{other}': '{tok}'"
2346            )),
2347        }
2348    }
2349
2350    /// Parse one argument to an AMPL imported function. An argument
2351    /// is either a normal expression (real-valued) or a string literal
2352    /// in the form `h<len>:<chars>`. AMPL emits string args only when the
2353    /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
2354    /// or a parameters-directory path for IDAES Helmholtz functions).
2355    fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
2356        // Peek the next non-blank line so we can route `h...` differently.
2357        let saved = self.pos;
2358        let raw = self
2359            .next_line()
2360            .ok_or_else(|| "expected funcall argument".to_string())?;
2361        // A string arg is a Hollerith literal `h<len>:<chars>` where the
2362        // chars are *exactly* `<len>` bytes and may legitimately contain
2363        // '#'. We must NOT strip a trailing comment before extracting the
2364        // content (that would truncate e.g. a path like `a#b`), and we
2365        // honor the declared length rather than splitting loosely on ':'.
2366        // Detect the form from the leading non-blank char of the raw line;
2367        // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
2368        let lead = raw.trim_start();
2369        if let Some(after_h) = lead.strip_prefix('h') {
2370            let colon = after_h
2371                .find(':')
2372                .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
2373            let len: usize = after_h[..colon]
2374                .trim()
2375                .parse()
2376                .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
2377            let chars = &after_h[colon + 1..];
2378            if chars.len() < len {
2379                return Err(format!(
2380                    "Hollerith string shorter than declared length {len}: {chars:?}"
2381                ));
2382            }
2383            // Take exactly `len` bytes; anything past it (trailing
2384            // whitespace, a real comment) is not part of the string.
2385            if !chars.is_char_boundary(len) {
2386                return Err(format!(
2387                    "Hollerith length {len} splits a multibyte char in {chars:?}"
2388                ));
2389            }
2390            Ok(FuncallArg::Str(chars[..len].to_string()))
2391        } else {
2392            // Rewind: parse_expr re-consumes the line we just peeked.
2393            self.pos = saved;
2394            Ok(FuncallArg::Real(self.parse_expr()?))
2395        }
2396    }
2397
2398    fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
2399        match code {
2400            0 => {
2401                let a = self.parse_expr()?;
2402                let b = self.parse_expr()?;
2403                Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
2404            }
2405            1 => {
2406                let a = self.parse_expr()?;
2407                let b = self.parse_expr()?;
2408                Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
2409            }
2410            2 => {
2411                let a = self.parse_expr()?;
2412                let b = self.parse_expr()?;
2413                Ok(Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b)))
2414            }
2415            3 => {
2416                let a = self.parse_expr()?;
2417                let b = self.parse_expr()?;
2418                Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
2419            }
2420            5 => {
2421                let a = self.parse_expr()?;
2422                let b = self.parse_expr()?;
2423                Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
2424            }
2425            15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
2426            16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
2427            39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
2428            41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
2429            42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
2430            43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
2431            44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
2432            46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
2433            38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
2434            49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
2435            53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
2436            40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
2437            45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
2438            37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
2439            51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
2440            52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
2441            50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
2442            47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
2443            // atan2(y, x): binary, operand order `y` then `x`.
2444            48 => {
2445                let a = self.parse_expr()?;
2446                let b = self.parse_expr()?;
2447                Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
2448            }
2449            // Relational comparisons (binary). Operand order is
2450            // `left OP right`.
2451            22 => self.parse_compare(CmpOp::Lt),
2452            23 => self.parse_compare(CmpOp::Le),
2453            24 => self.parse_compare(CmpOp::Eq),
2454            28 => self.parse_compare(CmpOp::Ge),
2455            29 => self.parse_compare(CmpOp::Gt),
2456            30 => self.parse_compare(CmpOp::Ne),
2457            // Logical connectives.
2458            20 => {
2459                let a = self.parse_expr()?;
2460                let b = self.parse_expr()?;
2461                Ok(Expr::Or(Box::new(a), Box::new(b)))
2462            }
2463            21 => {
2464                let a = self.parse_expr()?;
2465                let b = self.parse_expr()?;
2466                Ok(Expr::And(Box::new(a), Box::new(b)))
2467            }
2468            34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
2469            // if-then-else: condition, then-value, else-value.
2470            35 => {
2471                let cond = self.parse_expr()?;
2472                let then_ = self.parse_expr()?;
2473                let else_ = self.parse_expr()?;
2474                Ok(Expr::Cond {
2475                    cond: Box::new(cond),
2476                    then_: Box::new(then_),
2477                    else_: Box::new(else_),
2478                })
2479            }
2480            54 => {
2481                // Variadic sum: next data line gives the count.
2482                let count_line = self.next_data_line()?;
2483                let count: usize = count_line
2484                    .split_whitespace()
2485                    .next()
2486                    .ok_or_else(|| "missing variadic count".to_string())?
2487                    .parse()
2488                    .map_err(|e| format!("variadic count: {e}"))?;
2489                let mut args = Vec::with_capacity(count);
2490                for _ in 0..count {
2491                    args.push(self.parse_expr()?);
2492                }
2493                Ok(Expr::Sum(args))
2494            }
2495            // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
2496            // a count data line followed by that many operands.
2497            11 | 12 => {
2498                let count_line = self.next_data_line()?;
2499                let count: usize = count_line
2500                    .split_whitespace()
2501                    .next()
2502                    .ok_or_else(|| "missing min/max list count".to_string())?
2503                    .parse()
2504                    .map_err(|e| format!("min/max list count: {e}"))?;
2505                let mut args = Vec::with_capacity(count);
2506                for _ in 0..count {
2507                    args.push(self.parse_expr()?);
2508                }
2509                if code == 11 {
2510                    Ok(Expr::MinList(args))
2511                } else {
2512                    Ok(Expr::MaxList(args))
2513                }
2514            }
2515            // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
2516            // emits these in place of the general `o5` (OPPOW) as a hint that
2517            // one operand is constant. The distinction exists because an
2518            // integer / half-integer constant power is evaluated by a
2519            // mul/sqrt chain that stays real for a negative base, whereas the
2520            // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
2521            // they read exactly like `o5`, so they lower to the same `Pow` AST
2522            // and reuse the existing constant-power tape lowering (see
2523            // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
2524            // against the ASL reader and the `ampl/mp` opcode table:
2525            // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
2526            // unary with an implicit exponent of 2.
2527            //
2528            // o81 OP1POW: `base ^ (const exponent)` — binary, operands
2529            // `base` then `exp` (the exponent is a numeric node here).
2530            81 => {
2531                let base = self.parse_expr()?;
2532                let exp = self.parse_expr()?;
2533                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2534            }
2535            // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
2536            82 => {
2537                let base = self.parse_expr()?;
2538                Ok(Expr::Binary(
2539                    BinOp::Pow,
2540                    Box::new(base),
2541                    Box::new(Expr::Const(2.0)),
2542                ))
2543            }
2544            // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
2545            // (the numeric node) then `exp`.
2546            83 => {
2547                let base = self.parse_expr()?;
2548                let exp = self.parse_expr()?;
2549                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2550            }
2551            other => Err(format!("unsupported opcode o{other}")),
2552        }
2553    }
2554
2555    /// Parse the two operands of a relational opcode into an
2556    /// [`Expr::Compare`]. Operand order is `left OP right`.
2557    fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
2558        let a = self.parse_expr()?;
2559        let b = self.parse_expr()?;
2560        Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
2561    }
2562
2563    /// Resolve a `v<i>` token into either a plain variable reference
2564    /// (`i < n`) or a shared CSE reference (`i >= n`).
2565    fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
2566        if i < self.n {
2567            Ok(Expr::Var(i))
2568        } else {
2569            let local = i - self.n;
2570            self.cses
2571                .get(local)
2572                .map(|rc| Expr::Cse(rc.clone()))
2573                .ok_or_else(|| {
2574                    format!(
2575                        "v{i} references CSE {local} but only {} have been defined",
2576                        self.cses.len()
2577                    )
2578                })
2579        }
2580    }
2581
2582    /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
2583    /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
2584    /// CSEs are numbered starting at `n` and must appear in order.
2585    fn parse_v_segment(&mut self) -> Result<(), String> {
2586        let (hdr, _) = self.eat_segment_header()?;
2587        let parts: Vec<&str> = hdr.split_whitespace().collect();
2588        if parts.len() < 2 {
2589            return Err(format!("malformed V-segment header: {hdr}"));
2590        }
2591        let cse_idx = parse_segment_index(parts[0], 'V')?;
2592        let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
2593        // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
2594        let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
2595        for _ in 0..nlin {
2596            let line = self.next_data_line()?;
2597            let (var, coef) = parse_var_coef(line)?;
2598            linear.push((var, coef));
2599        }
2600        let nonlin = self.parse_expr()?;
2601        // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
2602        // reference earlier CSEs as well as plain variables.
2603        let mut combined = nonlin;
2604        for (var, coef) in linear {
2605            let v_expr = self.var_or_cse(var)?;
2606            let term = if coef == 1.0 {
2607                v_expr
2608            } else {
2609                Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
2610            };
2611            combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
2612        }
2613        if cse_idx < self.n {
2614            return Err(format!("V{cse_idx} below n={}", self.n));
2615        }
2616        let local = cse_idx - self.n;
2617        if local != self.cses.len() {
2618            return Err(format!(
2619                "V-segment index V{cse_idx} out of order; expected V{}",
2620                self.n + self.cses.len()
2621            ));
2622        }
2623        // What a reference to this body would mean to the streaming
2624        // recognizer, answered once here rather than per reference. The
2625        // `V` tree exists at this point, so these are the *same* functions
2626        // `NlTnlp` applies to a whole row — the parse-time recognizer never
2627        // gets a second opinion about a CSE.
2628        if self.quad_enabled {
2629            self.cse_quad
2630                .push(crate::nl_quadratic::recognize_expr(&combined));
2631            self.cse_sum_ok
2632                .push(crate::nl_quadratic::is_expanded_quadratic(&combined));
2633            self.cse_mono_ok
2634                .push(crate::nl_quadratic::is_monomial_expr(&combined));
2635            let mut vars: BTreeSet<usize> = BTreeSet::new();
2636            collect_vars(&combined, &mut vars);
2637            self.cse_vars
2638                .push(vars.into_iter().map(|v| v as u32).collect());
2639            self.cse_depth.push(expr_tree_depth(&combined));
2640        }
2641        self.cses.push(Arc::new(combined));
2642        Ok(())
2643    }
2644}
2645
2646/// Combine the operands of one recognized operator, mirroring
2647/// [`crate::nl_quadratic::recognize_expr`]'s `Apply` arm term for term.
2648///
2649/// Every difference from that function is a difference in the coefficients
2650/// this parser stores, so there is nothing here that is "equivalent but
2651/// tidier": the division scales by the reciprocal because that is what the
2652/// tree walk does, and the sumlist folds back to front for the same reason.
2653/// Nesting depth of a `V`-segment body, leaf = 1, a `Cse` reference
2654/// counting one level above its body — the convention `pounce-py`'s
2655/// `expr_depth` uses, since that is the guard the answer feeds.
2656///
2657/// Recursive, and safe to be: this only ever runs on a tree
2658/// [`Parser::parse_expr`] has just built *recursively* on this same stack,
2659/// so a frame that fits the parser fits this.
2660fn expr_tree_depth(e: &Expr) -> u32 {
2661    let deepest = |kids: &mut dyn Iterator<Item = &Expr>| {
2662        kids.fold(0u32, |acc, k| acc.max(expr_tree_depth(k)))
2663    };
2664    1 + match e {
2665        Expr::Const(_) | Expr::Var(_) => 0,
2666        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
2667            deepest(&mut [&**a, &**b].into_iter())
2668        }
2669        Expr::Unary(_, a) | Expr::Not(a) => expr_tree_depth(a),
2670        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => deepest(&mut args.iter()),
2671        Expr::Cond { cond, then_, else_ } => {
2672            deepest(&mut [&**cond, &**then_, &**else_].into_iter())
2673        }
2674        Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
2675            FuncallArg::Real(inner) => Some(inner),
2676            FuncallArg::Str(_) => None,
2677        })),
2678        Expr::Cse(body) => expr_tree_depth(body),
2679    }
2680}
2681
2682fn apply_quad_op(op: QOp, vals: &mut Vec<Quad2>) -> Option<Quad2> {
2683    let pop2 = |vals: &mut Vec<Quad2>| -> Option<(Quad2, Quad2)> {
2684        let b = vals.pop()?;
2685        let a = vals.pop()?;
2686        Some((a, b))
2687    };
2688    Some(match op {
2689        QOp::Sum(n) => {
2690            let at = vals.len().checked_sub(n)?;
2691            let mut acc = Quad2::default();
2692            // Operands arrive in file order, so `drain` already yields them
2693            // front to back — the order `recognize_expr` folds in, and the
2694            // order the AD tape sums in. Do not reverse this: floating-point
2695            // addition is not associative, and on repeated monomials the two
2696            // orders do not agree bit for bit.
2697            for p in vals.drain(at..) {
2698                acc = Quad2::add(acc, p);
2699            }
2700            acc
2701        }
2702        QOp::Neg => vals.pop()?.neg(),
2703        QOp::Add => {
2704            let (a, b) = pop2(vals)?;
2705            Quad2::add(a, b)
2706        }
2707        QOp::Sub => {
2708            let (a, b) = pop2(vals)?;
2709            Quad2::add(a, b.neg())
2710        }
2711        QOp::Mul => {
2712            let (a, b) = pop2(vals)?;
2713            a.mul(&b)?
2714        }
2715        QOp::Div => {
2716            let (a, b) = pop2(vals)?;
2717            let d = b.as_constant()?;
2718            if d == 0.0 {
2719                return None;
2720            }
2721            let mut out = a.div_by_constant(d);
2722            out.absorb_flags(&b);
2723            out
2724        }
2725        QOp::Pow => {
2726            let (a, b) = pop2(vals)?;
2727            let exp = b.as_constant()?;
2728            let mut out = if exp == 0.0 {
2729                Quad2::of_constant(1.0)
2730            } else if exp == 1.0 {
2731                a
2732            } else if exp == 2.0 {
2733                a.mul(&a)?
2734            } else {
2735                return None;
2736            };
2737            // The exponent is read out of a form with `as_constant`, which
2738            // leaves its flags behind; `Div` above does the same.
2739            out.absorb_flags(&b);
2740            out
2741        }
2742        // `o82` is `Pow(base, Const(2.0))` with the exponent left implicit,
2743        // so it takes the `exp == 2.0` branch above.
2744        QOp::Square => {
2745            let a = vals.pop()?;
2746            a.mul(&a)?
2747        }
2748    })
2749}
2750
2751/// Re-parse one recognized body from the bytes it was recognized from.
2752///
2753/// The point is that this is the *same* function the original parse ran, on
2754/// the *same* bytes, with the same `n` and the same `V`-segment bodies — so
2755/// the tree it returns is the tree that parse would have built, down to the
2756/// `Arc` identities that `HybridTape::build_multi` keys CSE sharing on.
2757/// Deriving an equivalent tree from the stored coefficients instead would
2758/// be a different tree, evaluated by a different tape, and this phase would
2759/// stop being invisible from outside.
2760fn parse_body_fragment(txt: &str, n: usize, cses: &[Arc<Expr>]) -> Result<Expr, String> {
2761    let mut p = Parser::new(txt, false);
2762    p.n = n;
2763    p.cses = cses.to_vec();
2764    p.parse_expr()
2765}
2766
2767fn strip_comment(s: &str) -> &str {
2768    match s.find('#') {
2769        Some(i) => &s[..i],
2770        None => s,
2771    }
2772}
2773
2774fn split_comment(s: &str) -> (&str, &str) {
2775    match s.find('#') {
2776        Some(i) => (&s[..i], &s[i + 1..]),
2777        None => (s, ""),
2778    }
2779}
2780
2781// --------------------------------------------------------------------
2782// Expression evaluation and gradient (tree walkers, kept for tests).
2783// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
2784// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
2785// --------------------------------------------------------------------
2786
2787/// Forward-mode value evaluation.
2788pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
2789    match e {
2790        Expr::Const(c) => *c,
2791        Expr::Var(i) => x[*i],
2792        Expr::Binary(op, a, b) => {
2793            let va = eval_expr(a, x);
2794            let vb = eval_expr(b, x);
2795            match op {
2796                BinOp::Add => va + vb,
2797                BinOp::Sub => va - vb,
2798                BinOp::Mul => va * vb,
2799                BinOp::Div => va / vb,
2800                BinOp::Pow => va.powf(vb),
2801                BinOp::Atan2 => va.atan2(vb),
2802                BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
2803            }
2804        }
2805        Expr::Unary(op, a) => {
2806            let va = eval_expr(a, x);
2807            match op {
2808                UnaryOp::Neg => -va,
2809                UnaryOp::Sqrt => va.sqrt(),
2810                UnaryOp::Log => va.ln(),
2811                UnaryOp::Log10 => va.log10(),
2812                UnaryOp::Exp => va.exp(),
2813                UnaryOp::Abs => va.abs(),
2814                UnaryOp::Sin => va.sin(),
2815                UnaryOp::Cos => va.cos(),
2816                UnaryOp::Tan => va.tan(),
2817                UnaryOp::Atan => va.atan(),
2818                UnaryOp::Acos => va.acos(),
2819                UnaryOp::Sinh => va.sinh(),
2820                UnaryOp::Cosh => va.cosh(),
2821                UnaryOp::Tanh => va.tanh(),
2822                UnaryOp::Asin => va.asin(),
2823                UnaryOp::Acosh => va.acosh(),
2824                UnaryOp::Asinh => va.asinh(),
2825                UnaryOp::Atanh => va.atanh(),
2826                UnaryOp::Erf => crate::nl_tape::erf(va),
2827                UnaryOp::XLogX => crate::nl_tape::xlogx(va),
2828            }
2829        }
2830        Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
2831        Expr::MinList(args) => args
2832            .iter()
2833            .map(|a| eval_expr(a, x))
2834            .fold(Number::INFINITY, Number::min),
2835        Expr::MaxList(args) => args
2836            .iter()
2837            .map(|a| eval_expr(a, x))
2838            .fold(Number::NEG_INFINITY, Number::max),
2839        Expr::Compare(op, a, b) => {
2840            let va = eval_expr(a, x);
2841            let vb = eval_expr(b, x);
2842            let truth = match op {
2843                CmpOp::Lt => va < vb,
2844                CmpOp::Le => va <= vb,
2845                CmpOp::Eq => va == vb,
2846                CmpOp::Ge => va >= vb,
2847                CmpOp::Gt => va > vb,
2848                CmpOp::Ne => va != vb,
2849            };
2850            if truth { 1.0 } else { 0.0 }
2851        }
2852        Expr::And(a, b) => {
2853            if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
2854                1.0
2855            } else {
2856                0.0
2857            }
2858        }
2859        Expr::Or(a, b) => {
2860            if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
2861                1.0
2862            } else {
2863                0.0
2864            }
2865        }
2866        Expr::Not(a) => {
2867            if eval_expr(a, x) == 0.0 {
2868                1.0
2869            } else {
2870                0.0
2871            }
2872        }
2873        Expr::Cond { cond, then_, else_ } => {
2874            if eval_expr(cond, x) != 0.0 {
2875                eval_expr(then_, x)
2876            } else {
2877                eval_expr(else_, x)
2878            }
2879        }
2880        Expr::Cse(body) => eval_expr(body, x),
2881        Expr::Funcall { .. } => panic!(
2882            "eval_expr: AMPL imported function called without an external resolver; \
2883             evaluate through the tape AD path (Tape::build_with_externals) instead"
2884        ),
2885    }
2886}
2887
2888/// Index of the active operand of an n-ary min (`want_min = true`) or
2889/// max (`want_min = false`) list at point `x`: the smallest / largest
2890/// value, with ties resolved to the first such operand (the
2891/// conventional subgradient choice). Returns `None` for an empty list.
2892fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
2893    let mut best: Option<(usize, Number)> = None;
2894    for (i, a) in args.iter().enumerate() {
2895        let v = eval_expr(a, x);
2896        match best {
2897            None => best = Some((i, v)),
2898            Some((_, bv)) => {
2899                // Strict comparison keeps the FIRST extremal operand on
2900                // ties, matching the subgradient convention used by Abs
2901                // and Select elsewhere in the tape.
2902                if (want_min && v < bv) || (!want_min && v > bv) {
2903                    best = Some((i, v));
2904                }
2905            }
2906        }
2907    }
2908    best.map(|(i, _)| i)
2909}
2910
2911/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
2912pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
2913    match e {
2914        Expr::Const(_) => {}
2915        Expr::Var(i) => grad[*i] += seed,
2916        Expr::Binary(op, a, b) => {
2917            let va = eval_expr(a, x);
2918            let vb = eval_expr(b, x);
2919            match op {
2920                BinOp::Add => {
2921                    grad_expr(a, x, seed, grad);
2922                    grad_expr(b, x, seed, grad);
2923                }
2924                BinOp::Sub => {
2925                    grad_expr(a, x, seed, grad);
2926                    grad_expr(b, x, -seed, grad);
2927                }
2928                BinOp::Mul => {
2929                    grad_expr(a, x, seed * vb, grad);
2930                    grad_expr(b, x, seed * va, grad);
2931                }
2932                BinOp::Div => {
2933                    grad_expr(a, x, seed / vb, grad);
2934                    grad_expr(b, x, -seed * va / (vb * vb), grad);
2935                }
2936                BinOp::Pow => {
2937                    // d/da: b * a^(b-1)
2938                    let dpa = vb * va.powf(vb - 1.0);
2939                    grad_expr(a, x, seed * dpa, grad);
2940                    // d/db: a^b * ln(a) (only valid for a>0; simple branch)
2941                    if va > 0.0 {
2942                        let dpb = va.powf(vb) * va.ln();
2943                        grad_expr(b, x, seed * dpb, grad);
2944                    }
2945                }
2946                BinOp::Atan2 => {
2947                    // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
2948                    let d = va * va + vb * vb;
2949                    grad_expr(a, x, seed * vb / d, grad);
2950                    grad_expr(b, x, -seed * va / d, grad);
2951                }
2952                BinOp::CEntropy => {
2953                    grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
2954                    grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
2955                }
2956            }
2957        }
2958        Expr::Unary(op, a) => {
2959            let va = eval_expr(a, x);
2960            let d = match op {
2961                UnaryOp::Neg => -1.0,
2962                UnaryOp::Sqrt => 0.5 / va.sqrt(),
2963                UnaryOp::Log => 1.0 / va,
2964                UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
2965                UnaryOp::Exp => va.exp(),
2966                UnaryOp::Abs => {
2967                    if va > 0.0 {
2968                        1.0
2969                    } else if va < 0.0 {
2970                        -1.0
2971                    } else {
2972                        0.0
2973                    }
2974                }
2975                UnaryOp::Sin => va.cos(),
2976                UnaryOp::Cos => -va.sin(),
2977                UnaryOp::Tan => {
2978                    let t = va.tan();
2979                    1.0 + t * t
2980                }
2981                UnaryOp::Atan => 1.0 / (1.0 + va * va),
2982                UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
2983                UnaryOp::Sinh => va.cosh(),
2984                UnaryOp::Cosh => va.sinh(),
2985                UnaryOp::Tanh => {
2986                    let t = va.tanh();
2987                    1.0 - t * t
2988                }
2989                UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
2990                UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
2991                UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
2992                UnaryOp::Atanh => 1.0 / (1.0 - va * va),
2993                UnaryOp::Erf => crate::nl_tape::erf_d1(va),
2994                UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
2995            };
2996            grad_expr(a, x, seed * d, grad);
2997        }
2998        Expr::Sum(args) => {
2999            for arg in args {
3000                grad_expr(arg, x, seed, grad);
3001            }
3002        }
3003        // min/max are piecewise linear: the seed flows only through the
3004        // currently-active (smallest / largest) operand — a subgradient.
3005        // Ties resolve to the first such operand. Empty list: no operand,
3006        // no derivative (matches the ±inf eval fold).
3007        Expr::MinList(args) => {
3008            if let Some(k) = argmin_argmax(args, x, true) {
3009                grad_expr(&args[k], x, seed, grad);
3010            }
3011        }
3012        Expr::MaxList(args) => {
3013            if let Some(k) = argmin_argmax(args, x, false) {
3014                grad_expr(&args[k], x, seed, grad);
3015            }
3016        }
3017        // Comparisons and logical connectives are piecewise constant:
3018        // zero derivative, so no seed propagates into their operands.
3019        Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3020        // if-then-else: differentiate only the active branch. The
3021        // branch-switch discontinuity contributes no derivative.
3022        Expr::Cond { cond, then_, else_ } => {
3023            if eval_expr(cond, x) != 0.0 {
3024                grad_expr(then_, x, seed, grad);
3025            } else {
3026                grad_expr(else_, x, seed, grad);
3027            }
3028        }
3029        Expr::Cse(body) => grad_expr(body, x, seed, grad),
3030        Expr::Funcall { .. } => {
3031            panic!("grad_expr: AMPL imported function called without an external resolver")
3032        }
3033    }
3034}
3035
3036/// Walk `e` and insert every `Var(i)` index into `out`.
3037///
3038/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3039/// identity. Without that this is Θ(2^depth) on a DAG that shares
3040/// subexpressions — each reference re-walks the whole body — and presolve
3041/// calls this on every solve (`get_variables_linearity`). Skipping a
3042/// repeat visit cannot change the answer: `out` is a set, and a second
3043/// walk of the same body inserts exactly the indices the first already did.
3044pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3045    // `HashSet::new` does not allocate until the first insert, so an
3046    // expression with no CSEs pays nothing for the memo.
3047    let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3048    collect_vars_memo(e, out, &mut seen);
3049}
3050
3051fn collect_vars_memo(
3052    e: &Expr,
3053    out: &mut BTreeSet<usize>,
3054    seen: &mut std::collections::HashSet<*const Expr>,
3055) {
3056    match e {
3057        Expr::Const(_) => {}
3058        Expr::Var(i) => {
3059            out.insert(*i);
3060        }
3061        Expr::Binary(_, a, b) => {
3062            collect_vars_memo(a, out, seen);
3063            collect_vars_memo(b, out, seen);
3064        }
3065        Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3066        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3067            for a in args {
3068                collect_vars_memo(a, out, seen);
3069            }
3070        }
3071        // Collect from every child, including the condition: even
3072        // though the comparison/branch-test contributes no derivative,
3073        // the variables it reads are genuinely "used" by the problem,
3074        // and being conservative here only ever adds structural zeros
3075        // to the Jacobian/Hessian (never drops a real nonzero).
3076        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3077            collect_vars_memo(a, out, seen);
3078            collect_vars_memo(b, out, seen);
3079        }
3080        Expr::Not(a) => collect_vars_memo(a, out, seen),
3081        Expr::Cond { cond, then_, else_ } => {
3082            collect_vars_memo(cond, out, seen);
3083            collect_vars_memo(then_, out, seen);
3084            collect_vars_memo(else_, out, seen);
3085        }
3086        Expr::Cse(body) => {
3087            if seen.insert(Arc::as_ptr(body)) {
3088                collect_vars_memo(body, out, seen);
3089            }
3090        }
3091        Expr::Funcall { args, .. } => {
3092            for a in args {
3093                if let FuncallArg::Real(e) = a {
3094                    collect_vars_memo(e, out, seen);
3095                }
3096            }
3097        }
3098    }
3099}
3100
3101// --------------------------------------------------------------------
3102// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3103// Jacobian, and Hessian. Built once at construction; every solve-time
3104// callback is a tape sweep, no expression-tree recursion.
3105// --------------------------------------------------------------------
3106
3107/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3108/// After a directional Hessian-vector product `compressed = H · s_c`,
3109/// the entry at row `row` came uniquely from column `col` (because
3110/// no two columns of color `c` share any nonzero row), so we
3111/// scatter `compressed[row]` into `values[hess_idx]`.
3112#[derive(Debug, Clone)]
3113struct ColorWrite {
3114    row: u32,
3115    hess_idx: u32,
3116}
3117
3118/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3119/// **shared prelude** holding every CSE body referenced by two or more
3120/// summands, evaluated once per sweep.
3121///
3122/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3123/// defined variable (`V` segment) referenced from many rows is re-emitted —
3124/// and re-evaluated — once per reference. That is invisible on most models
3125/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3126/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3127/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3128/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3129/// search. See pounce#476.
3130///
3131/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3132/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3133/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3134/// overhead the flat tapes do not, so each derivative order has to earn
3135/// its switch.
3136#[derive(Debug, Clone)]
3137struct ConHybrid {
3138    tape: HybridTape,
3139    /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3140    /// Length `m + 1`.
3141    row_start: Vec<usize>,
3142    /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3143    prelude_vals: Vec<f64>,
3144    /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3145    local_vals: Vec<f64>,
3146    /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3147    /// value arenas above. `gradient_summand` zeroes only the slots a
3148    /// summand actually reaches, so these are allocated once and reused.
3149    local_adj: Vec<f64>,
3150    prelude_adj: Vec<f64>,
3151    /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3152    /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3153    /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3154    use_for_jac: bool,
3155    /// Whether `eval_h` routes the constraint block through the shared
3156    /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3157    use_for_hess: bool,
3158    // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3159    // `try_new` after the Hessian coloring exists; cheap enough (a few
3160    // index tables plus one f64 per local op) to build whenever the
3161    // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3162    /// Forward values of every summand, packed at
3163    /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3164    /// fills it; every color then reuses the values, mirroring the flat
3165    /// path's forward-once-per-tape structure.
3166    local_vals_all: Vec<f64>,
3167    /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3168    local_off: Vec<usize>,
3169    /// Constraint row of each summand (the inverse of `row_start`), for
3170    /// the `λ[row]` weight lookup.
3171    summand_row: Vec<u32>,
3172    /// Per color: the summands whose variables fall in that color — the
3173    /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3174    /// exactly the live (color, summand) pairs.
3175    hess_color_summands: Vec<Vec<u32>>,
3176    /// Per color: the prelude slots that color's summands actually reach,
3177    /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3178    /// Both prelude sweeps run once per color, so walking the whole
3179    /// prelude each time would cost `n_colors × |prelude|` where the
3180    /// op-ratio gate assumes `|prelude|`; iterating the union of the
3181    /// color's `prelude_reach` sets makes the cost proportional to what
3182    /// is used, so the gate does not need an `n_colors` term. Stored as
3183    /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3184    /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3185    /// this struct is the one #552 made O(n²) by holding per-color dense
3186    /// arrays.
3187    hess_color_reach: Vec<u32>,
3188    hess_color_reach_off: Vec<usize>,
3189    /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3190    prelude_dot: Vec<f64>,
3191    /// First-/second-order prelude adjoint accumulators. NOT shared
3192    /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3193    /// between-colors invariant (`prelude_reverse_directional`'s
3194    /// consume-and-zero contract), while `gradient_summand` zeroes only
3195    /// the slots it is about to use and leaves them dirty afterwards —
3196    /// sharing the buffer would seed a later `eval_h` with a stale
3197    /// Jacobian adjoint.
3198    hess_prelude_adj: Vec<f64>,
3199    prelude_adj_dot: Vec<f64>,
3200    /// Local tangent / second-order adjoint arenas, sized to
3201    /// `tape.max_summand_ops()`.
3202    local_dot: Vec<f64>,
3203    local_adj_dot: Vec<f64>,
3204}
3205
3206/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3207/// shared-CSE prelude.
3208///
3209/// `eval_g` takes the hybrid path unconditionally because it only needs
3210/// *values*: the prelude is swept once for the whole constraint block and
3211/// the saving is the full op-count ratio. The Jacobian is different. Each
3212/// row needs its own gradient, so only the forward sweep can be shared —
3213/// the reverse sweep still walks each summand's `prelude_reach`
3214/// separately, and it pays a per-op cost the flat tape does not: a nested
3215/// `SummandOp` dispatch and an indirected walk over a reach list instead
3216/// of a straight loop over a contiguous `Vec<TapeOp>`.
3217///
3218/// So the hybrid Jacobian wins only when the shared bodies are large
3219/// enough for the halved forward sweep to outweigh that overhead.
3220/// Measured on chain models at CSE redundancy 40, varying the body size
3221/// (`eval_jac_g`, flat → hybrid):
3222///
3223/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3224/// |---|---|---|---|---|---|---|---|---|
3225/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3226///
3227/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3228/// a model that does not clearly benefit stays on the flat path. For
3229/// reference `robot_a` (#476) measures 4.03×.
3230const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3231
3232/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3233/// block through the shared-CSE prelude (issue #557).
3234///
3235/// The Hessian shares **both** second-order sweeps of the prelude, not just
3236/// the forward one: the coloring hands every summand of a color the same
3237/// seed vector, so the prelude forward tangent runs once per color, and —
3238/// because reverse-over-tangent is linear in its adjoint seeds — the
3239/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3240/// unit-weight prelude reverse sweep per color. That is why its crossover
3241/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3242/// Jacobian can only share the forward half, and per-row gradients forbid
3243/// batching its reverse sweeps at all.
3244///
3245/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3246/// bodies), varying the body size — the same protocol as the Jacobian
3247/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3248/// flat/hybrid pairs per point**, with the observed range, because
3249/// single runs on a shared machine are not reproducible to the precision
3250/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3251/// single ratio:
3252///
3253/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3254/// |---|---|---|---|---|---|---|---|---|
3255/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3256/// | 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 |
3257///
3258/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3259/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3260/// break-even and individual runs lose. Setting it there costs only a
3261/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3262/// bit-identically, so a gate placed too high is merely conservative while
3263/// one placed too low risks a real regression. For reference `robot_a`
3264/// (#476) measures 4.03×.
3265const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3266
3267// `Clone` supports the batched-solve path (pounce#126): one parsed
3268// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3269// the clone is cheap relative to a solve) and each clone gets its own
3270// bound / starting-point overrides via [`NlTnlp::variant`].
3271#[derive(Debug, Clone)]
3272pub struct NlTnlp {
3273    prob: NlProblem,
3274    /// Per-summand objective tapes (one `Tape` per top-level
3275    /// summand after `split_top_sums`).
3276    obj_tapes: Vec<Tape>,
3277    /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3278    /// one `Tape` per summand of constraint `i`.
3279    con_tapes: Vec<Vec<Tape>>,
3280    /// Constraint-block tape with a **shared** CSE prelude, used by
3281    /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3282    /// `eval_g` on the per-summand `con_tapes` above.
3283    con_hybrid: Option<ConHybrid>,
3284    /// The degree-≤2 objective and rows, evaluated from their constant
3285    /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3286    /// has an **empty** `con_tapes` entry, so every loop over the tapes
3287    /// naturally contributes nothing for it and only the sites that consult
3288    /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3289    /// `eval_h`, and `hessian_vector_products` — the last of which the design
3290    /// note's list of five omitted, and which is the one where an omission
3291    /// would be silent. Empty for a model with nothing recognized, in which
3292    /// case everything below behaves bit for bit as it did before this
3293    /// existed.
3294    quad: QuadraticStructure,
3295    /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3296    /// Empty when `quad` is — the pattern is then all tape. The coloring is
3297    /// built over this subset alone, which is what stops one dense quadratic
3298    /// block from forcing the color count to `n` for the benefit of tapes
3299    /// that no longer exist.
3300    h_tape_mask: Vec<bool>,
3301    /// Lower-triangle Hessian sparsity (row >= col), one entry per
3302    /// structurally nonzero second derivative in the Lagrangian.
3303    h_irow: Vec<i32>,
3304    h_jcol: Vec<i32>,
3305    /// Per-row sorted variable indices for the constraint Jacobian.
3306    jac_cols: Vec<Vec<usize>>,
3307    jac_nnz: usize,
3308    /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3309    /// is in color `c`, else `0.0`. Each color is a set of
3310    /// variables whose Hessian columns have pairwise-disjoint
3311    /// nonzero rows; one directional H·s product per color
3312    /// recovers all those columns simultaneously. Dense for
3313    /// O(1) lookup in the per-op forward tangent.
3314    seeds: Vec<Vec<f64>>,
3315    /// Per-color decoding table: for each `(row, hess_idx)` entry,
3316    /// scatter `compressed_c[row] -> values[hess_idx]` after the
3317    /// per-color directional product.
3318    decoding: Vec<Vec<ColorWrite>>,
3319    /// For each objective tape: the distinct colors of vars it
3320    /// references. Lets us skip tape × color pairs where the tape
3321    /// has zero overlap with the color's seed.
3322    obj_tape_colors: Vec<Vec<u32>>,
3323    /// Same as `obj_tape_colors` but per constraint × summand.
3324    con_tape_colors: Vec<Vec<Vec<u32>>>,
3325    /// Color of each variable's Hessian column, `u32::MAX` for a column
3326    /// that needs no pass of its own. Kept so
3327    /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3328    /// pass in `compressed`.
3329    var_color: Vec<u32>,
3330    /// Columns peeled out of the conflict structure and given singleton
3331    /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3332    peeled_cols: Vec<u32>,
3333    final_x: Option<Vec<Number>>,
3334    final_obj: Number,
3335    /// Converged constraint multipliers (length `m`, original `.nl` row
3336    /// order, user convention), captured from the same `finalize_solution`
3337    /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3338    /// block without re-deriving it from the algorithm's internal `y_c` /
3339    /// `y_d` split and scaling.
3340    final_lambda: Option<Vec<Number>>,
3341    /// Converged bound multipliers (length `n` each, Ipopt's internal
3342    /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3343    /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3344    /// reads for reduced costs.
3345    final_z_l: Option<Vec<Number>>,
3346    final_z_u: Option<Vec<Number>>,
3347    /// Per-row Jacobian accumulator (length n).
3348    scratch_row_grad: Vec<f64>,
3349    /// Scratch buffers for `Tape::hessian_directional` (each sized
3350    /// to `max_tape_n`).
3351    vals_scratch: Vec<f64>,
3352    dot_scratch: Vec<f64>,
3353    adj_scratch: Vec<f64>,
3354    adj_dot_scratch: Vec<f64>,
3355    /// Per-color compressed Hessian-vector results, sized to
3356    /// `prob.n`. Reused across `eval_h` calls but allocated once.
3357    compressed: Vec<Vec<f64>>,
3358    /// Per-direction "carries signal" mask for `hessian_vector_products`,
3359    /// kept here rather than allocated per call. This crate holds an
3360    /// explicit no-per-call-allocation line on the tape sweeps (see
3361    /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3362    /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3363    /// Krylov iteration.
3364    hvp_live: Vec<bool>,
3365    /// Model-derived scaling factors (gh #703), computed on demand by
3366    /// [`NlTnlp::enable_curvature_scaling`] and served through
3367    /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3368    /// the state every solve that does not select `curvature-based` stays
3369    /// in — computing them costs a pass over every stored Hessian entry
3370    /// and nothing else reads them.
3371    curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3372}
3373
3374// ---------------------------------------------------------------------
3375// Human-readable equation rendering (`print equation` in the debugger).
3376//
3377// Turns a parsed constraint back into infix text using the model's
3378// variable / constraint names, so the debugger can show the actual
3379// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3380// bare row index. This is the "print the specific equation, with
3381// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3382// argue makes equation-oriented model diagnostics actionable.
3383//
3384// The renderer is intentionally separate from the evaluation `Tape`:
3385// tapes are lossy for display (CSEs flattened, externals opaque),
3386// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3387// ---------------------------------------------------------------------
3388
3389/// Binding strength for parenthesization. Higher binds tighter.
3390const P_ADD: u8 = 10;
3391const P_MUL: u8 = 20;
3392const P_NEG: u8 = 30;
3393const P_POW: u8 = 40;
3394const P_ATOM: u8 = 100;
3395
3396/// Format a numeric literal compactly: integers without a trailing `.0`,
3397/// everything else via the shortest round-tripping `f64` form.
3398fn fmt_num(x: Number) -> String {
3399    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3400        format!("{}", x as i64)
3401    } else {
3402        format!("{x}")
3403    }
3404}
3405
3406/// Display label for variable `i`: its `.col` name when present, else
3407/// `x[i]`.
3408fn var_label(i: usize, var_names: &[String]) -> String {
3409    match var_names.get(i) {
3410        Some(s) if !s.is_empty() => s.clone(),
3411        _ => format!("x[{i}]"),
3412    }
3413}
3414
3415/// Precedence of an expression's top operator (for child wrapping).
3416fn expr_prec(e: &Expr) -> u8 {
3417    match e {
3418        Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3419        Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3420        Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3421        Expr::Binary(BinOp::Pow, ..) => P_POW,
3422        Expr::Cse(inner) => expr_prec(inner),
3423        // Everything else renders as an atom / `f(...)` form.
3424        _ => P_ATOM,
3425    }
3426}
3427
3428/// Render an expression as infix text, using `var_names` for variable
3429/// labels where available (`x[i]` otherwise).
3430///
3431/// The debugger reaches the renderer through the constraint/objective
3432/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3433/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3434/// able to *see* the expression you just built is most of the debugging
3435/// story.
3436///
3437/// `Cse` bodies are inlined at every occurrence, so the output of a
3438/// heavily-shared DAG can be far larger than the DAG itself. Callers
3439/// rendering user-built expressions should bound the input first.
3440pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3441    render_expr(e, var_names, &[])
3442}
3443
3444/// Render `e`, wrapping in parentheses iff its precedence is looser than
3445/// `min_prec`.
3446fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
3447    let s = render_expr(e, vn, funcs);
3448    if expr_prec(e) < min_prec {
3449        format!("({s})")
3450    } else {
3451        s
3452    }
3453}
3454
3455fn unary_name(op: UnaryOp) -> &'static str {
3456    match op {
3457        UnaryOp::Neg => "-",
3458        UnaryOp::Sqrt => "sqrt",
3459        UnaryOp::Log => "log",
3460        UnaryOp::Exp => "exp",
3461        UnaryOp::Abs => "abs",
3462        UnaryOp::Sin => "sin",
3463        UnaryOp::Cos => "cos",
3464        UnaryOp::Log10 => "log10",
3465        UnaryOp::Tan => "tan",
3466        UnaryOp::Atan => "atan",
3467        UnaryOp::Acos => "acos",
3468        UnaryOp::Sinh => "sinh",
3469        UnaryOp::Cosh => "cosh",
3470        UnaryOp::Tanh => "tanh",
3471        UnaryOp::Asin => "asin",
3472        UnaryOp::Acosh => "acosh",
3473        UnaryOp::Asinh => "asinh",
3474        UnaryOp::Atanh => "atanh",
3475        UnaryOp::Erf => "erf",
3476        // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
3477        // the rendered text is read by humans debugging a model and must not
3478        // imply a sign the op does not have.
3479        UnaryOp::XLogX => "xlogx",
3480    }
3481}
3482
3483fn cmp_sym(op: CmpOp) -> &'static str {
3484    match op {
3485        CmpOp::Lt => "<",
3486        CmpOp::Le => "<=",
3487        CmpOp::Eq => "==",
3488        CmpOp::Ge => ">=",
3489        CmpOp::Gt => ">",
3490        CmpOp::Ne => "!=",
3491    }
3492}
3493
3494/// Append an additive sub-term with a tidy sign: a rendered term that
3495/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
3496/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
3497/// the term is itself a sum. The first term is emitted verbatim.
3498fn push_additive(out: &mut String, rendered: &str, first: bool) {
3499    if first {
3500        out.push_str(rendered);
3501    } else if let Some(rest) = rendered.strip_prefix('-') {
3502        out.push_str(" - ");
3503        out.push_str(rest);
3504    } else {
3505        out.push_str(" + ");
3506        out.push_str(rendered);
3507    }
3508}
3509
3510/// Render an [`Expr`] DAG to infix text using model names.
3511fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
3512    match e {
3513        Expr::Const(c) => fmt_num(*c),
3514        Expr::Var(i) => var_label(*i, vn),
3515        Expr::Binary(op, l, r) => match op {
3516            BinOp::Add => {
3517                let mut s = render_prec(l, P_ADD, vn, funcs);
3518                push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
3519                s
3520            }
3521            // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
3522            BinOp::Sub => format!(
3523                "{} - {}",
3524                render_prec(l, P_ADD, vn, funcs),
3525                render_prec(r, P_ADD + 1, vn, funcs)
3526            ),
3527            BinOp::Mul => format!(
3528                "{}*{}",
3529                render_prec(l, P_MUL, vn, funcs),
3530                render_prec(r, P_MUL, vn, funcs)
3531            ),
3532            BinOp::Div => format!(
3533                "{}/{}",
3534                render_prec(l, P_MUL, vn, funcs),
3535                render_prec(r, P_MUL + 1, vn, funcs)
3536            ),
3537            // Pow is right-associative: tighten the left operand instead.
3538            BinOp::Pow => format!(
3539                "{}^{}",
3540                render_prec(l, P_POW + 1, vn, funcs),
3541                render_prec(r, P_POW, vn, funcs)
3542            ),
3543            BinOp::Atan2 => format!(
3544                "atan2({}, {})",
3545                render_expr(l, vn, funcs),
3546                render_expr(r, vn, funcs)
3547            ),
3548            BinOp::CEntropy => format!(
3549                "centropy({}, {})",
3550                render_expr(l, vn, funcs),
3551                render_expr(r, vn, funcs)
3552            ),
3553        },
3554        Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
3555        Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
3556        Expr::Sum(xs) => {
3557            if xs.is_empty() {
3558                "0".to_string()
3559            } else {
3560                let mut s = String::new();
3561                for (k, x) in xs.iter().enumerate() {
3562                    push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
3563                }
3564                s
3565            }
3566        }
3567        Expr::Cse(inner) => render_expr(inner, vn, funcs),
3568        Expr::Funcall { id, args } => {
3569            let name = funcs
3570                .iter()
3571                .find(|f| f.id == *id)
3572                .map(|f| f.name.clone())
3573                .unwrap_or_else(|| format!("extern#{id}"));
3574            let parts: Vec<String> = args
3575                .iter()
3576                .map(|a| match a {
3577                    FuncallArg::Real(x) => render_expr(x, vn, funcs),
3578                    FuncallArg::Str(s) => format!("{s:?}"),
3579                })
3580                .collect();
3581            format!("{name}({})", parts.join(", "))
3582        }
3583        Expr::Compare(op, a, b) => format!(
3584            "({} {} {})",
3585            render_expr(a, vn, funcs),
3586            cmp_sym(*op),
3587            render_expr(b, vn, funcs)
3588        ),
3589        Expr::And(a, b) => format!(
3590            "({} && {})",
3591            render_expr(a, vn, funcs),
3592            render_expr(b, vn, funcs)
3593        ),
3594        Expr::Or(a, b) => format!(
3595            "({} || {})",
3596            render_expr(a, vn, funcs),
3597            render_expr(b, vn, funcs)
3598        ),
3599        Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
3600        Expr::Cond { cond, then_, else_ } => format!(
3601            "if({}, {}, {})",
3602            render_expr(cond, vn, funcs),
3603            render_expr(then_, vn, funcs),
3604            render_expr(else_, vn, funcs)
3605        ),
3606        Expr::MinList(xs) => format!(
3607            "min({})",
3608            xs.iter()
3609                .map(|x| render_expr(x, vn, funcs))
3610                .collect::<Vec<_>>()
3611                .join(", ")
3612        ),
3613        Expr::MaxList(xs) => format!(
3614            "max({})",
3615            xs.iter()
3616                .map(|x| render_expr(x, vn, funcs))
3617                .collect::<Vec<_>>()
3618                .join(", ")
3619        ),
3620    }
3621}
3622
3623/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
3624/// `a + -2*b`). Returns `""` when there are no linear terms.
3625fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
3626    let mut out = String::new();
3627    // The `.nl` linear part carries an entry for every variable in the
3628    // row's Jacobian, including a 0 coefficient for variables that appear
3629    // only *nonlinearly* (they're rendered in the nonlinear part). Skip
3630    // those zeros so the equation reads as written, not as a sparsity map.
3631    let mut first = true;
3632    for (var, coef) in linear {
3633        if *coef == 0.0 {
3634            continue;
3635        }
3636        let neg = *coef < 0.0;
3637        let mag = coef.abs();
3638        let term = if mag == 1.0 {
3639            var_label(*var, vn)
3640        } else {
3641            format!("{}*{}", fmt_num(mag), var_label(*var, vn))
3642        };
3643        if first {
3644            if neg {
3645                out.push('-');
3646            }
3647            out.push_str(&term);
3648            first = false;
3649        } else {
3650            out.push_str(if neg { " - " } else { " + " });
3651            out.push_str(&term);
3652        }
3653    }
3654    out
3655}
3656
3657/// Render the constraint body (linear + nonlinear parts combined).
3658fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
3659    let mut s = render_linear(linear, &prob.var_names);
3660    let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
3661    if !nl_is_zero {
3662        let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
3663        if s.is_empty() {
3664            s = nl;
3665        } else {
3666            push_additive(&mut s, &nl, false);
3667        }
3668    }
3669    if s.is_empty() {
3670        s = "0".to_string();
3671    }
3672    s
3673}
3674
3675/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
3676/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
3677/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
3678pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
3679    // Diagnostic path: a recognized body has no tree, so this is one of
3680    // the places that pays to rebuild one. It runs once per rendered row.
3681    let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
3682    let lo = prob.g_l[k];
3683    let hi = prob.g_u[k];
3684    const INF: Number = 1.0e19;
3685    let has_lo = lo > -INF;
3686    let has_hi = hi < INF;
3687    match (has_lo, has_hi) {
3688        (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
3689        (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
3690        (true, false) => format!("{body} >= {}", fmt_num(lo)),
3691        (false, true) => format!("{body} <= {}", fmt_num(hi)),
3692        (false, false) => format!("{body}  (free)"),
3693    }
3694}
3695
3696/// Render every constraint to text, index-aligned to `g` (original `.nl`
3697/// row order). Used to build the debugger's static equation book.
3698pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
3699    (0..prob.m)
3700        .map(|k| render_constraint_equation(prob, k))
3701        .collect()
3702}
3703
3704/// Structural sparsity of the constraint Jacobian as flat 0-based
3705/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
3706/// structurally depends on — the union of its linear support and the
3707/// `Var(i)` indices appearing anywhere in its nonlinear tree
3708/// ([`collect_vars`]). Sorted and deduplicated within each row.
3709///
3710/// This is the input to the debugger's Dulmage–Mendelsohn
3711/// structural-rank check (`diagnose`), which names the over-determined
3712/// (candidate redundant / inconsistent) equations and under-determined
3713/// variables. Naming the dependent rows — rather than reporting
3714/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
3715/// equation-oriented model debugging. See
3716/// <https://doi.org/10.69997/sct.147875>.
3717pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
3718    let mut irow: Vec<Index> = Vec::new();
3719    let mut jcol: Vec<Index> = Vec::new();
3720    let mut support: BTreeSet<usize> = BTreeSet::new();
3721    for k in 0..prob.m {
3722        support.clear();
3723        for &(j, _coef) in &prob.con_linear[k] {
3724            support.insert(j);
3725        }
3726        prob.con_nonlinear[k].collect_vars(&mut support);
3727        for &j in &support {
3728            irow.push(k as Index);
3729            jcol.push(j as Index);
3730        }
3731    }
3732    (irow, jcol)
3733}
3734
3735/// Flatten an additive expression tree into independent summand
3736/// expressions, each of which becomes its own Hessian tape.
3737///
3738/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
3739/// each summand tape once *per color the summand touches*, so the
3740/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
3741/// small (few variables → few colors) is what makes a sparse Hessian
3742/// cheap. A single fused tape spanning all `n` variables, by
3743/// contrast, is walked once per color → `O(n · tape_len)`, which on a
3744/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
3745/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
3746///
3747/// We therefore descend through the *affine* envelope of the sum, not
3748/// just `+`/`Sum`:
3749///
3750///   * `Neg(x)`            → split `x`, negate each summand
3751///   * `Sub(l, r)`         → split `l`; split `r`, negate each summand
3752///   * `c * x` / `x * c`   → split `x`, scale each summand by `c`
3753///   * `x / c`             → split `x`, scale each summand by `1/c`
3754///
3755/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
3756/// least-squares / max-entropy shapes) still decomposes to its leaf
3757/// terms instead of collapsing into one giant tape. The carried
3758/// `factor` is materialised onto each leaf only when it differs from
3759/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
3760/// is unchanged and the per-summand op count grows by at most one.
3761fn split_top_sums(expr: &Expr) -> Vec<Expr> {
3762    let mut out = Vec::new();
3763    fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
3764        if factor == 1.0 {
3765            out.push(e.clone());
3766        } else if factor == -1.0 {
3767            out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
3768        } else {
3769            out.push(Expr::Binary(
3770                BinOp::Mul,
3771                Box::new(Expr::Const(factor)),
3772                Box::new(e.clone()),
3773            ));
3774        }
3775    }
3776    fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
3777        match e {
3778            Expr::Sum(terms) => {
3779                for t in terms {
3780                    go(t, factor, out);
3781                }
3782            }
3783            Expr::Binary(BinOp::Add, l, r) => {
3784                go(l, factor, out);
3785                go(r, factor, out);
3786            }
3787            Expr::Binary(BinOp::Sub, l, r) => {
3788                go(l, factor, out);
3789                go(r, -factor, out);
3790            }
3791            Expr::Unary(UnaryOp::Neg, x) => {
3792                go(x, -factor, out);
3793            }
3794            // Affine scaling: distribute a constant coefficient into
3795            // the summands so a leading `c·(Σ …)` still splits.
3796            Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
3797                (Expr::Const(c), _) => go(r, factor * c, out),
3798                (_, Expr::Const(c)) => go(l, factor * c, out),
3799                _ => push_leaf(e, factor, out),
3800            },
3801            Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
3802                Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
3803                _ => push_leaf(e, factor, out),
3804            },
3805            _ => push_leaf(e, factor, out),
3806        }
3807    }
3808    go(expr, 1.0, &mut out);
3809    if out.is_empty() {
3810        out.push(Expr::Const(0.0));
3811    }
3812    out
3813}
3814
3815/// Greedy column coloring of a symmetric sparsity pattern stored
3816/// as lower-triangle pairs.
3817///
3818/// Builds the column-intersection graph: columns `c1` and `c2` are
3819/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
3820/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
3821/// satisfies the direct-recovery condition for symmetric Hessians
3822/// (Coleman-Moré): for any color, the columns it contains have
3823/// pairwise disjoint row supports, so a single H·s product
3824/// recovers them all unambiguously.
3825///
3826/// Returns `(var_color, n_colors)` where `var_color[k]` is the
3827/// color assigned to variable `k`, or `u32::MAX` for variables
3828/// not in any Hessian pair (they contribute nothing and don't
3829/// need a color).
3830/// A column is treated as **dense** — and peeled out of the coloring —
3831/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
3832/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
3833/// factor keeps uniformly-dense Hessians (where every column looks like
3834/// every other) on the plain coloring path, and the absolute floor stops
3835/// a very sparse average from declaring a 10-entry column "dense".
3836const DENSE_COL_FACTOR: usize = 16;
3837const DENSE_COL_MIN: usize = 32;
3838/// Largest relative error a peeled column may inflict on the smallest
3839/// entry recovered from its pass before
3840/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
3841///
3842/// The ratio is a worst-case bound — the pass's roundoff floor over the
3843/// smallest entry read out of it — and it is a *loose* one, by an amount
3844/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
3845/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
3846/// measures 4e-16. The cut is therefore calibrated on the corpus, not
3847/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
3848/// models that peel anything, the highest bound on a column that recovers
3849/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
3850/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
3851/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
3852///
3853/// 1e-8 sits below both, which deliberately buys correctness with speed:
3854/// the errors are asymmetric, since a false veto costs one model a coloring
3855/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
3856/// while a missed veto costs a solve its certificate. Five of the 56 take a
3857/// veto they do not need; none takes a wrong answer. Raising this constant
3858/// to buy those five back would put the cut inside a 2.8x window measured
3859/// on two model families, which is not a margin worth trading a certificate
3860/// for.
3861const PEEL_MAX_REL_ERR: f64 = 1e-8;
3862/// Hard cap on how many columns get peeled, applied on top of the
3863/// pay-for-itself rule in [`select_peeled_cols`].
3864const MAX_PEELED_COLS: usize = 256;
3865
3866/// Lower bound on the color count that results from peeling `peeled`.
3867///
3868/// Peeling costs one color per peeled column. On what remains, any row
3869/// with `d` surviving entries makes those `d` columns pairwise
3870/// conflicting, so the greedy walk needs at least `d` colors. Hence
3871/// `|peeled| + max surviving row degree` is a lower bound on the total,
3872/// computable in one O(nnz) pass — no coloring required.
3873///
3874/// The bound is what makes the choice decidable at all: the plain
3875/// coloring cannot be run as a comparison baseline, because on the
3876/// one-dense-row case the plain walk is itself O(n^2) — precisely the
3877/// blowup peeling exists to avoid.
3878fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
3879    let mut deg = vec![0usize; n];
3880    for &(i, j) in lower_pairs {
3881        if peeled[i] || peeled[j] {
3882            continue;
3883        }
3884        deg[j] += 1;
3885        if i != j {
3886            deg[i] += 1;
3887        }
3888    }
3889    let n_peeled = peeled.iter().filter(|&&p| p).count();
3890    n_peeled + deg.iter().copied().max().unwrap_or(0)
3891}
3892
3893/// Choose which of the candidate dense columns to actually peel.
3894///
3895/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
3896/// top `k` candidates by degree, over a doubling ladder of `k` up to
3897/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
3898/// so peeling has to earn its colors.
3899///
3900/// **Why not just truncate an over-long candidate list.** Cutting the
3901/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
3902/// columns that miss the cut stay in the conflict structure, so the base
3903/// color count is untouched and the singleton colors are pure addition.
3904/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
3905/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
3906/// regression in exactly the quantity peeling exists to reduce. The
3907/// bound above sees it: peeling `k` of several thousand equal-degree
3908/// columns leaves the surviving max degree unchanged, so every `k > 0`
3909/// scores strictly worse than peeling nothing.
3910///
3911/// **Why not a simple degree rule.** "Peel only columns denser than some
3912/// fraction of `n`" would refuse three rows of degree 10,000 in a
3913/// 200,000-variable model, where peeling three columns takes the
3914/// coloring from >= 10,000 down to a handful. The win depends on what
3915/// peeling leaves behind, not on the peeled column's degree alone.
3916fn select_peeled_cols(
3917    n: usize,
3918    lower_pairs: &[(usize, usize)],
3919    deg: &[usize],
3920    mut candidates: Vec<usize>,
3921) -> Vec<usize> {
3922    if candidates.is_empty() {
3923        return candidates;
3924    }
3925    // Worst offenders first; they remove the most conflict per color spent.
3926    candidates.sort_unstable_by(|&a, &b| deg[b].cmp(&deg[a]).then(a.cmp(&b)));
3927    candidates.truncate(MAX_PEELED_COLS);
3928
3929    let mut mask = vec![false; n];
3930    let mut best_k = 0usize;
3931    // Peeling nothing: the bound is just the largest row degree.
3932    let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
3933
3934    // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
3935    // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
3936    // mask only ever gains entries, so each step just marks the new slice.
3937    let mut marked = 0usize;
3938    let mut k = 1usize;
3939    loop {
3940        let k_now = k.min(candidates.len());
3941        for &j in &candidates[marked..k_now] {
3942            mask[j] = true;
3943        }
3944        marked = k_now;
3945        let bound = peel_color_bound(n, lower_pairs, &mask);
3946        if bound < best_bound {
3947            best_bound = bound;
3948            best_k = k_now;
3949        }
3950        if k_now == candidates.len() {
3951            break;
3952        }
3953        k *= 2;
3954    }
3955
3956    candidates.truncate(best_k);
3957    candidates
3958}
3959
3960/// Greedy distance-1 coloring of the Hessian's column-intersection
3961/// graph, with **dense columns peeled out**.
3962///
3963/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
3964/// marks a column that needs no directional product of its own: either
3965/// it has no Hessian entries at all, or every entry it has is recovered
3966/// from a peeled column's pass (see below).
3967///
3968/// # Why peeling
3969///
3970/// The plain coloring rule is "two columns may share a color when they
3971/// have no common nonzero row". A single **dense row** — one variable
3972/// multiplying a sum over all the others, a total-cost variable, a
3973/// shared design parameter — puts a nonzero in *every* column at that
3974/// row, so every pair of columns conflicts and the greedy walk hands out
3975/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
3976/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
3977/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
3978/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
3979///
3980/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
3981/// own singleton color: one directional product with seed `e_d` recovers
3982/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
3983/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
3984/// at row `j` of that same pass. So row `d` no longer constrains any
3985/// other column's color and is dropped from the conflict structure, and
3986/// the remaining columns colour on their genuine sparsity. On the
3987/// one-dense-row case above this takes `n_colors` from `n` to a handful.
3988///
3989/// # What peeling costs, and `peel_veto`
3990///
3991/// Recovering `H[d, j]` from column `d`'s pass is exact in real
3992/// arithmetic but not in floating point: the pass is accumulated at the
3993/// scale of the whole dense column, so every entry read out of it carries
3994/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
3995/// ordinary path — column `j`'s own pass — would have left a floor of
3996/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
3997/// dense column is well scaled, and they do on every peel-firing model in
3998/// the benchmark corpus but one. Where a peeled column spans a wide
3999/// dynamic range, though, that floor swamps its small entries: a column
4000/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4001///
4002/// Structure cannot see this — it is a property of the values — so
4003/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4004/// and passes the offenders back here in `peel_veto`, which bars them
4005/// from being peeled again. A vetoed column is colored normally, its row
4006/// returns to the conflict structure, and its entries go back to the
4007/// accurate path.
4008fn greedy_hessian_coloring(
4009    n: usize,
4010    lower_pairs: &[(usize, usize)],
4011    peel_veto: &[bool],
4012) -> (Vec<u32>, usize, Vec<bool>) {
4013    if n == 0 {
4014        return (Vec::new(), 0, Vec::new());
4015    }
4016
4017    // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4018    // i >= j contributes row i to column j and row j to column i; a
4019    // diagonal contributes once.
4020    let mut deg = vec![0usize; n];
4021    for &(i, j) in lower_pairs {
4022        deg[j] += 1;
4023        if i != j {
4024            deg[i] += 1;
4025        }
4026    }
4027
4028    // Pick the dense columns to peel.
4029    let total: usize = deg.iter().sum();
4030    let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4031    let mut peeled = vec![false; n];
4032    let candidates: Vec<usize> = (0..n)
4033        .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4034        .collect();
4035    let dense = select_peeled_cols(n, lower_pairs, &deg, candidates);
4036    for &j in &dense {
4037        peeled[j] = true;
4038    }
4039
4040    // Conflict structure over the *non-peeled* columns only. Pairs with
4041    // a peeled endpoint are recovered from that endpoint's own pass, so
4042    // they neither need a color nor constrain one.
4043    let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4044    let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4045    for &(i, j) in lower_pairs {
4046        if peeled[i] || peeled[j] {
4047            continue;
4048        }
4049        col_rows[j].push(i as u32);
4050        row_cols[i].push(j as u32);
4051        if i != j {
4052            col_rows[i].push(j as u32);
4053            row_cols[j].push(i as u32);
4054        }
4055    }
4056
4057    let mut var_color = vec![u32::MAX; n];
4058    let mut forbidden = vec![u32::MAX; n + 1];
4059    let mut n_colors: u32 = 0;
4060
4061    for j in 0..n {
4062        // Peeled columns are colored below; a column with no surviving
4063        // Hessian entries needs no color at all.
4064        if peeled[j] || col_rows[j].is_empty() {
4065            continue;
4066        }
4067        // Mark colors used by any column sharing a row with `j`.
4068        // Row-of-col -> col-in-row visit pattern collects all
4069        // distance-1 neighbors in the column-intersection graph.
4070        for &r in &col_rows[j] {
4071            for &c in &row_cols[r as usize] {
4072                if c as usize == j {
4073                    continue;
4074                }
4075                let cc = var_color[c as usize];
4076                if cc != u32::MAX {
4077                    forbidden[cc as usize] = j as u32;
4078                }
4079            }
4080        }
4081        // First color not stamped with `j as u32`.
4082        let mut chosen: u32 = 0;
4083        while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4084            chosen += 1;
4085        }
4086        var_color[j] = chosen;
4087        if chosen + 1 > n_colors {
4088            n_colors = chosen + 1;
4089        }
4090    }
4091
4092    // One singleton color per peeled column, appended after the shared
4093    // ones so the non-peeled numbering is untouched.
4094    for &j in &dense {
4095        var_color[j] = n_colors;
4096        n_colors += 1;
4097    }
4098
4099    (var_color, n_colors as usize, peeled)
4100}
4101
4102/// Everything downstream of the Hessian coloring: seed vectors, the
4103/// per-color decode table, the per-tape color sets, and the shared-CSE
4104/// per-color summand / prelude-reach tables.
4105///
4106/// Split out of [`NlTnlp::new`] because
4107/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4108/// time, with a peel veto in hand, once it has seen real Hessian values.
4109fn build_color_tables(
4110    n: usize,
4111    m: usize,
4112    lower_pairs: &[(usize, usize)],
4113    tape_mask: &[bool],
4114    peel_veto: &[bool],
4115    obj_tapes: &[Tape],
4116    con_tapes: &[Vec<Tape>],
4117    con_hybrid: Option<&mut ConHybrid>,
4118) -> ColorTables {
4119    // Hessian column coloring. The chromatic number of the
4120    // column-intersection graph bounds how many directional
4121    // Hessian-vector products we need per `eval_h` call —
4122    // typically O(stencil) for PDE-mesh problems.
4123    //
4124    // Only the entries a *tape* can write take part. `tape_mask` is empty
4125    // when every entry is a tape entry (no quadratic structure), which is
4126    // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4127    // entries are excluded, since they are scattered from their stored
4128    // values and never read out of a directional product. A variable that
4129    // appears only in quadratic rows then has no color at all
4130    // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4131    // column with no surviving entries.
4132    let colored_pairs: Vec<(usize, usize)>;
4133    let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4134        lower_pairs
4135    } else {
4136        colored_pairs = lower_pairs
4137            .iter()
4138            .zip(tape_mask)
4139            .filter_map(|(p, &t)| t.then_some(*p))
4140            .collect();
4141        &colored_pairs
4142    };
4143    let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4144
4145    // Per-color seed vectors (dense for O(1) Var lookup in
4146    // `Tape::hessian_directional`).
4147    let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4148    for (k, &c) in var_color.iter().enumerate() {
4149        if c != u32::MAX {
4150            seeds[c as usize][k] = 1.0;
4151        }
4152    }
4153
4154    // Per-color decoding table. For each lower-tri pair (i, j)
4155    // with i >= j, the entry belongs to column j's color: after
4156    // computing compressed_{c_j} = (H · s_{c_j}), the value at
4157    // row i is exactly H[i, j] (coloring guarantees no other
4158    // column in c_j has a nonzero at row i).
4159    // Built straight from `lower_pairs`, which is sorted, so each
4160    // color's table is in ascending `hess_idx` order and the decode
4161    // scatter walks `values` forward instead of hopping (the old
4162    // build drained a `HashMap`, whose iteration order is arbitrary).
4163    let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4164    for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4165        // An entry no tape writes has no pass to be decoded out of; the
4166        // quadratic scatter already put its value there.
4167        if !tape_mask.is_empty() && !tape_mask[idx] {
4168            continue;
4169        }
4170        // Which directional product recovers H[i, j]? Column `j`'s,
4171        // read at row `i` — except when `i` is a peeled column and
4172        // `j` is not: then `j` may have no color of its own, and the
4173        // entry is already in column `i`'s pass at row `j`, since
4174        // H[i, j] == H[j, i].
4175        let (c, row) = if peeled[i] && !peeled[j] {
4176            (var_color[i], j)
4177        } else {
4178            (var_color[j], i)
4179        };
4180        debug_assert!(
4181            c != u32::MAX,
4182            "Hessian pair ({i}, {j}) at index {idx} has no color"
4183        );
4184        decoding[c as usize].push(ColorWrite {
4185            row: row as u32,
4186            hess_idx: idx as u32,
4187        });
4188    }
4189
4190    // Per-tape distinct color set: for each tape, the colors
4191    // its variables fall into. `eval_h` loops over only these
4192    // (tape, color) pairs instead of n_tapes × n_colors.
4193    let tape_colors = |t: &Tape| -> Vec<u32> {
4194        let mut s: Vec<u32> = t
4195            .variables()
4196            .into_iter()
4197            .map(|v| var_color[v])
4198            .filter(|&c| c != u32::MAX)
4199            .collect();
4200        s.sort_unstable();
4201        s.dedup();
4202        s
4203    };
4204    let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4205    let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4206        .iter()
4207        .map(|row| row.iter().map(tape_colors).collect())
4208        .collect();
4209
4210    // Shared-CSE Hessian tables (issue #557): the per-color summand
4211    // lists, row lookup, and packed forward-value arena `eval_h`'s
4212    // hybrid path walks. Built whenever the hybrid tape is — not just
4213    // above the gate — so flipping `use_for_hess` on (tests, the
4214    // force env var) needs no extra setup; the cost is one f64 per
4215    // local op plus small index tables.
4216    if let Some(h) = con_hybrid {
4217        let n_sum = h.tape.n_summands();
4218        let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4219        let mut acc = 0usize;
4220        for s in &h.tape.summands {
4221            local_off.push(acc);
4222            acc += s.ops.len();
4223        }
4224        local_off.push(acc);
4225        h.local_vals_all = vec![0.0; acc];
4226        h.local_off = local_off;
4227
4228        let mut summand_row = vec![0u32; n_sum];
4229        for i in 0..m {
4230            for si in h.row_start[i]..h.row_start[i + 1] {
4231                summand_row[si] = i as u32;
4232            }
4233        }
4234        h.summand_row = summand_row;
4235
4236        // A summand's variable set (`all_vars`) equals its flat tape's,
4237        // so this is `con_tape_colors` inverted to color-major order —
4238        // the loop `eval_h` actually runs.
4239        let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4240        for (si, s) in h.tape.summands.iter().enumerate() {
4241            let mut cs: Vec<u32> = s
4242                .all_vars
4243                .iter()
4244                .map(|&v| var_color[v])
4245                .filter(|&c| c != u32::MAX)
4246                .collect();
4247            cs.sort_unstable();
4248            cs.dedup();
4249            for c in cs {
4250                by_color[c as usize].push(si as u32);
4251            }
4252        }
4253        // Per-color prelude reach: the union of `prelude_reach` over the
4254        // color's summands, ascending. A union of operand-closed
4255        // ascending sets is itself operand-closed and ascending, which is
4256        // exactly what the two prelude sweeps require. Deduped with an
4257        // epoch-tagged buffer so the build costs
4258        // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4259        // it saves — rather than `n_colors × |prelude|`.
4260        let np = h.tape.n_prelude_ops();
4261        let mut seen: Vec<u32> = vec![0; np];
4262        let mut epoch: u32 = 0;
4263        let mut reach: Vec<u32> = Vec::new();
4264        let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4265        for list in &by_color {
4266            reach_off.push(reach.len());
4267            epoch += 1;
4268            let start = reach.len();
4269            for &si in list {
4270                for &p in &h.tape.summands[si as usize].prelude_reach {
4271                    if seen[p] != epoch {
4272                        seen[p] = epoch;
4273                        reach.push(p as u32);
4274                    }
4275                }
4276            }
4277            reach[start..].sort_unstable();
4278        }
4279        reach_off.push(reach.len());
4280        h.hess_color_reach = reach;
4281        h.hess_color_reach_off = reach_off;
4282
4283        h.hess_color_summands = by_color;
4284        h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4285        h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4286        h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4287        h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4288        h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4289    }
4290
4291    ColorTables {
4292        var_color,
4293        n_colors,
4294        peeled_cols: peeled
4295            .iter()
4296            .enumerate()
4297            .filter(|(_, p)| **p)
4298            .map(|(j, _)| j as u32)
4299            .collect(),
4300        seeds,
4301        decoding,
4302        obj_tape_colors,
4303        con_tape_colors,
4304    }
4305}
4306
4307/// The color-dependent half of an [`NlTnlp`], as built by
4308/// [`build_color_tables`].
4309struct ColorTables {
4310    var_color: Vec<u32>,
4311    n_colors: usize,
4312    /// Columns given a singleton color and dropped from the conflict
4313    /// structure. Small by construction (`MAX_PEELED_COLS`).
4314    peeled_cols: Vec<u32>,
4315    seeds: Vec<Vec<f64>>,
4316    decoding: Vec<Vec<ColorWrite>>,
4317    obj_tape_colors: Vec<Vec<u32>>,
4318    con_tape_colors: Vec<Vec<Vec<u32>>>,
4319}
4320
4321impl NlTnlp {
4322    /// Build the TNLP, panicking if AMPL external-function resolution fails.
4323    ///
4324    /// Kept for the many infallible call sites (CLI, tests) that operate on
4325    /// `.nl` models known to need no external libraries. Surfaces that can be
4326    /// handed an arbitrary user model — notably the Python `read_nl` binding —
4327    /// must call [`Self::try_new`] instead so a missing `$AMPLFUNC` library
4328    /// becomes a catchable error rather than an uncatchable panic across the
4329    /// pyo3 boundary.
4330    pub fn new(prob: NlProblem) -> Self {
4331        Self::try_new(prob)
4332            .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4333    }
4334
4335    /// Build the TNLP, returning an error (instead of panicking) when AMPL
4336    /// imported functions named by the model can't be resolved — e.g.
4337    /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4338    /// referenced function id isn't registered by any loaded library.
4339    pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4340        // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4341        // objective — the A/B reference for the constant-structure path,
4342        // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4343        // fast path's derivatives are checked against the ones they replace
4344        // on a real model, and how a suspected fast-path bug is bisected
4345        // against a reference that computes the same numbers a different way.
4346        Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4347    }
4348
4349    /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4350    /// explicitly on or off.
4351    ///
4352    /// The env var `try_new` reads is process-global, which is exactly wrong
4353    /// for the differential test that has to build the *same* model both ways
4354    /// and compare the derivatives — so the knob is a parameter here and the
4355    /// env var only chooses its default.
4356    pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4357        // Resolve any AMPL imported (external) functions. Walk every
4358        // nonlinear expression to collect the funcall ids actually
4359        // referenced; load the libraries named in $AMPLFUNC and bind
4360        // each id to its (library, registered-name) pair so the tape
4361        // builder can emit live `TapeOp::Funcall` ops.
4362        let mut referenced: BTreeSet<usize> = BTreeSet::new();
4363        // Only trees can carry a `Funcall`: the recognizer refuses one, so
4364        // a recognized body provably contains none.
4365        for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4366            if let Some(e) = body.tree() {
4367                super::nl_external::collect_funcall_ids(e, &mut referenced);
4368            }
4369        }
4370        let resolver = if referenced.is_empty() {
4371            super::nl_external::ExternalResolver::default()
4372        } else {
4373            super::nl_external::ExternalResolver::build_for_problem(
4374                &prob.imported_funcs,
4375                &referenced,
4376            )?
4377        };
4378
4379        // Recognize the degree-≤2 objective and rows *before* anything is
4380        // taped, because the win is not in evaluating the tape faster — it
4381        // is in never building it. On `qcqp500-3c` the ten quadratic rows
4382        // are 2.32 M monomials, one `Tape` each; recognizing them first
4383        // means those 2.32 M tapes, their color lists and their per-tape
4384        // sparsity sets are never allocated.
4385        //
4386        // A row that is trivially zero is left alone: it has no nonlinear
4387        // part to replace, and routing it through a (necessarily empty)
4388        // quadratic form would touch every model in the corpus to save
4389        // nothing.
4390        let mut quad = QuadraticStructure::new(prob.m);
4391        if use_quadratic {
4392            // `is_expanded_quadratic` is the accuracy gate, not an
4393            // optimization: it admits only forms whose read-out repeats the
4394            // additions the `.nl` writer already wrote. See its docs — and
4395            // note it is checked *before* recognition, so a factored form
4396            // costs one cheap structural walk rather than a full expansion.
4397            // Recognition is the parser's job now (gh #588, Q5) for the
4398            // bodies it could reach; `admitted_quad_form` reads its answer
4399            // back, and still walks a tree for the bodies that kept one —
4400            // a `from_expressions` model, a factored row rewound by the
4401            // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
4402            //
4403            // A form that gate refuses is offered the *factored* read-out
4404            // before it falls back to a tape (gh #673): the gate is a
4405            // verdict on the expansion, not on the body, and a sum of
4406            // squared residuals keeps its structure by keeping its squares.
4407            // `push_body_form` owns that order.
4408            if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
4409                quad.assign_objective(f);
4410            }
4411            for k in 0..prob.m {
4412                if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
4413                    quad.assign_row(k, f);
4414                }
4415            }
4416        }
4417
4418        // Flatten objective and each constraint into independent
4419        // summands. Each summand becomes its own `Tape` (CSE bodies
4420        // are deduplicated within a tape via Rc identity in
4421        // `Tape::build`; bodies shared across summands are
4422        // duplicated, which we accept as a simplicity tradeoff).
4423        let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
4424            Vec::new()
4425        } else {
4426            split_top_sums(&prob.obj_expr())
4427                .iter()
4428                .map(|e| Tape::build_with_externals(e, &resolver))
4429                .collect()
4430        };
4431
4432        let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
4433        let mut con_roots: Vec<Expr> = Vec::new();
4434        let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
4435        for k in 0..prob.m {
4436            row_start.push(con_roots.len());
4437            // A row with a quadratic form contributes no tape and no
4438            // hybrid-tape root, so `con_tapes[k]` is empty and its
4439            // `row_start` range is empty too.
4440            if quad.row_form(k).is_some() {
4441                con_tapes.push(Vec::new());
4442                continue;
4443            }
4444            let summands = split_top_sums(&prob.con_expr(k));
4445            con_tapes.push(
4446                summands
4447                    .iter()
4448                    .map(|e| Tape::build_with_externals(e, &resolver))
4449                    .collect(),
4450            );
4451            // Move (not clone) the split summands into the root list: their
4452            // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
4453            // sharing on `Arc` pointer identity, so the roots must be the
4454            // same allocations the parse produced.
4455            con_roots.extend(summands);
4456        }
4457        row_start.push(con_roots.len());
4458
4459        // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
4460        // building only when some CSE body is actually referenced from two
4461        // or more summands — otherwise the prelude comes out empty and the
4462        // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
4463        // gates the opcodes `build_multi` would panic on.
4464        // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
4465        // whole constraint block. Diagnostic only: it is how the
4466        // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
4467        // on a real model, and how a suspected hybrid-path bug is bisected
4468        // against a reference that computes the same derivatives a
4469        // different way.
4470        let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
4471            None
4472        } else if hybrid_supported(&con_roots) {
4473            let tape = HybridTape::build_multi(&con_roots);
4474            (tape.n_prelude_ops() > 0).then(|| {
4475                let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
4476                let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
4477                // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
4478                // regardless of the op ratio. Diagnostic only — it is how the
4479                // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
4480                // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
4481                // models that sit below the gate.
4482                let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
4483                ConHybrid {
4484                    prelude_vals: vec![0.0; tape.n_prelude_ops()],
4485                    local_vals: vec![0.0; tape.max_summand_ops()],
4486                    local_adj: vec![0.0; tape.max_summand_ops()],
4487                    prelude_adj: vec![0.0; tape.n_prelude_ops()],
4488                    use_for_jac: flat_ops as f64
4489                        >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
4490                    use_for_hess: force_hess
4491                        || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
4492                    local_vals_all: Vec::new(),
4493                    local_off: Vec::new(),
4494                    summand_row: Vec::new(),
4495                    hess_color_summands: Vec::new(),
4496                    hess_color_reach: Vec::new(),
4497                    hess_color_reach_off: Vec::new(),
4498                    prelude_dot: Vec::new(),
4499                    hess_prelude_adj: Vec::new(),
4500                    prelude_adj_dot: Vec::new(),
4501                    local_dot: Vec::new(),
4502                    local_adj_dot: Vec::new(),
4503                    row_start,
4504                    tape,
4505                }
4506            })
4507        } else {
4508            None
4509        };
4510        drop(con_roots);
4511
4512        // Hessian-of-Lagrangian sparsity: union of each tape's own
4513        // structural Hessian sparsity.
4514        // One flat `Vec`, sorted and deduped once, rather than a global
4515        // `BTreeSet` fed a single insert at a time across every summand
4516        // in the model: sort+dedup walks contiguous memory where the tree
4517        // chased a pointer and allocated a node per entry. The result is
4518        // exactly the ascending order the rest of this function wants, so
4519        // it doubles as `lower_pairs` instead of being copied into it.
4520        let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
4521        for t in &obj_tapes {
4522            tape_pairs.extend(t.hessian_sparsity());
4523        }
4524        for row in &con_tapes {
4525            for t in row {
4526                tape_pairs.extend(t.hessian_sparsity());
4527            }
4528        }
4529        tape_pairs.sort_unstable();
4530        tape_pairs.dedup();
4531
4532        // The assembled pattern is the tapes' union *plus* the quadratic
4533        // forms'. They are kept apart because the coloring only ever needs
4534        // the tape half — a quadratic block's entries are scattered
4535        // directly, not recovered from a directional product — and coloring
4536        // a dense quadratic block would put the color count back at `n` to
4537        // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
4538        let mut lower_pairs = tape_pairs.clone();
4539        if !quad.is_empty() {
4540            for f in quad
4541                .objective_form()
4542                .into_iter()
4543                .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
4544            {
4545                lower_pairs.extend(
4546                    quad.lower_triangle(f)
4547                        .map(|(r, c, _)| (r as usize, c as usize)),
4548                );
4549            }
4550            lower_pairs.sort_unstable();
4551            lower_pairs.dedup();
4552        }
4553
4554        // Which assembled entries a tape can write. Both sides are sorted
4555        // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
4556        // mask is left empty (meaning "all of them") when nothing was
4557        // recognized.
4558        let h_tape_mask: Vec<bool> = if quad.is_empty() {
4559            Vec::new()
4560        } else {
4561            let mut mask = vec![false; lower_pairs.len()];
4562            let mut t = 0usize;
4563            for (idx, pair) in lower_pairs.iter().enumerate() {
4564                if t < tape_pairs.len() && tape_pairs[t] == *pair {
4565                    mask[idx] = true;
4566                    t += 1;
4567                }
4568            }
4569            debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
4570            mask
4571        };
4572        drop(tape_pairs);
4573
4574        let mut h_irow = Vec::with_capacity(lower_pairs.len());
4575        let mut h_jcol = Vec::with_capacity(lower_pairs.len());
4576        for &(hi, lo) in &lower_pairs {
4577            h_irow.push(hi as i32);
4578            h_jcol.push(lo as i32);
4579        }
4580
4581        // Bind each form's lower-triangle entries to their index in the
4582        // assembled pattern. `lower_pairs` is sorted, so the lookup is a
4583        // binary search — done once here, never again on the hot path.
4584        if !quad.is_empty() {
4585            quad.bind_slots(|r, c| {
4586                lower_pairs
4587                    .binary_search(&(r as usize, c as usize))
4588                    .unwrap_or_else(|_| {
4589                        unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
4590                    })
4591            });
4592        }
4593
4594        // Hessian column coloring and everything keyed off it. The
4595        // chromatic number of the column-intersection graph bounds how
4596        // many directional Hessian-vector products we need per `eval_h`
4597        // call — typically O(stencil) for PDE-mesh problems.
4598        let ColorTables {
4599            var_color,
4600            n_colors,
4601            peeled_cols,
4602            seeds,
4603            decoding,
4604            obj_tape_colors,
4605            con_tape_colors,
4606        } = build_color_tables(
4607            prob.n,
4608            prob.m,
4609            &lower_pairs,
4610            &h_tape_mask,
4611            &vec![false; prob.n],
4612            &obj_tapes,
4613            &con_tapes,
4614            con_hybrid.as_mut(),
4615        );
4616
4617        // Per-row Jacobian sparsity = union of tape vars plus
4618        // linear-segment vars.
4619        let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
4620        let mut jac_nnz = 0;
4621        for (i, row_tapes) in con_tapes.iter().enumerate() {
4622            let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
4623            for t in row_tapes {
4624                cols.extend(t.variables());
4625            }
4626            // A quadratic row has no tape, so its Jacobian support comes
4627            // from the form: `Hx + a` is nonzero exactly on the union of the
4628            // Hessian's rows and the folded linear part.
4629            if let Some(f) = quad.row_form(i) {
4630                cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
4631            }
4632            cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
4633            cols.sort_unstable();
4634            cols.dedup();
4635            cols.shrink_to_fit();
4636            jac_nnz += cols.len();
4637            jac_cols.push(cols);
4638        }
4639
4640        let mut max_tape_n: usize = 0;
4641        for t in &obj_tapes {
4642            max_tape_n = max_tape_n.max(t.ops.len());
4643        }
4644        for row in &con_tapes {
4645            for t in row {
4646                max_tape_n = max_tape_n.max(t.ops.len());
4647            }
4648        }
4649
4650        if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
4651            let n_obj = obj_tapes.len();
4652            let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
4653            let total = n_obj + n_con;
4654            let mut sum_ops: usize = 0;
4655            for t in &obj_tapes {
4656                sum_ops += t.ops.len();
4657            }
4658            for row in &con_tapes {
4659                for t in row {
4660                    sum_ops += t.ops.len();
4661                }
4662            }
4663            let t = total.max(1);
4664            let nnz_h = h_irow.len();
4665            let avg_decode =
4666                decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
4667            eprintln!(
4668                "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
4669                 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
4670                 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h}",
4671                sum_ops as f64 / t as f64,
4672            );
4673            // Flat vs shared-CSE op counts for the constraint block. The
4674            // ratio is how much duplicated CSE work `eval_g`'s hybrid path
4675            // avoids, and the ceiling on what routing the Jacobian /
4676            // Hessian through the same prelude could save.
4677            match &con_hybrid {
4678                Some(h) => {
4679                    let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
4680                    let prelude = h.tape.n_prelude_ops();
4681                    let local = h.tape.total_local_ops();
4682                    eprintln!(
4683                        "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
4684                         local_ops={local} shared_total={} flat/shared={:.2}x \
4685                         jac_gate={} hess_gate={}",
4686                        prelude + local,
4687                        flat as f64 / (prelude + local).max(1) as f64,
4688                        if h.use_for_jac { "on" } else { "off" },
4689                        if h.use_for_hess { "on" } else { "off" },
4690                    );
4691                }
4692                None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
4693            }
4694            // What the constant-structure path took off the tape builder.
4695            // `forms` counts the objective too, which is why it can exceed
4696            // the row count by one.
4697            let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
4698            // How many of those forms the *parser* produced, i.e. how many
4699            // never cost an `Expr` (gh #588, Q5). The rest were recognized
4700            // from a tree that was built: a `from_expressions` model, or a
4701            // body the parser rewound because it was not degree 2.
4702            let parsed = std::iter::once(&prob.obj_nonlinear)
4703                .chain(prob.con_nonlinear.iter())
4704                .filter(|b| b.quad().is_some())
4705                .count();
4706            eprintln!(
4707                "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
4708                 stored_h_entries={} colored_pairs={}/{}",
4709                quad.len(),
4710                prob.m,
4711                if quad.objective_form().is_some() {
4712                    "quadratic"
4713                } else {
4714                    "taped"
4715                },
4716                quad.stored_entries(),
4717                if h_tape_mask.is_empty() {
4718                    lower_pairs.len()
4719                } else {
4720                    h_tape_mask.iter().filter(|&&t| t).count()
4721                },
4722                lower_pairs.len(),
4723            );
4724        }
4725
4726        let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
4727
4728        let mut me = Self {
4729            prob,
4730            obj_tapes,
4731            con_tapes,
4732            con_hybrid,
4733            quad,
4734            h_tape_mask,
4735            h_irow,
4736            h_jcol,
4737            jac_cols,
4738            jac_nnz,
4739            seeds,
4740            decoding,
4741            obj_tape_colors,
4742            con_tape_colors,
4743            var_color,
4744            peeled_cols,
4745            final_x: None,
4746            final_obj: 0.0,
4747            final_lambda: None,
4748            final_z_l: None,
4749            final_z_u: None,
4750            scratch_row_grad: Vec::new(),
4751            vals_scratch: vec![0.0; max_tape_n],
4752            dot_scratch: vec![0.0; max_tape_n],
4753            adj_scratch: vec![0.0; max_tape_n],
4754            adj_dot_scratch: vec![0.0; max_tape_n],
4755            compressed,
4756            hvp_live: Vec::new(),
4757            curvature_scaling: None,
4758        };
4759        me.veto_ill_conditioned_peels();
4760        Ok(me)
4761    }
4762
4763    /// Un-peel any dense column whose own pass is too ill-scaled to read
4764    /// its small entries out of, and re-color if that changes anything.
4765    ///
4766    /// `greedy_hessian_coloring` picks the peel set from structure alone,
4767    /// which is the right call for the memory and the color count but
4768    /// blind to the one thing that can go wrong: an entry recovered from
4769    /// column `d`'s pass inherits that pass's roundoff floor, about
4770    /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
4771    /// well-scaled dense column loses nothing to that — the recovered
4772    /// entries come back bit-identical to the uncompressed reference on
4773    /// every peel-firing model in the benchmark corpus but one. A column
4774    /// spanning many orders of magnitude, though, hands its small entries
4775    /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
4776    /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
4777    /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
4778    /// survives that, but the multipliers come out of the KKT system the
4779    /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
4780    /// the solve stalls short of `Optimal` on a problem it used to
4781    /// certify.
4782    ///
4783    /// Nothing structural distinguishes the two cases, so measure it: one
4784    /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
4785    /// column's exact pass sitting in `compressed`, and a column whose
4786    /// worst recovered entry would lose more than
4787    /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
4788    /// one `eval_h` — at the peeled color count, so cheap — and only for
4789    /// the ~3% of models that peel anything at all.
4790    fn veto_ill_conditioned_peels(&mut self) {
4791        if self.peeled_cols.is_empty() {
4792            return;
4793        }
4794
4795        let mut values = vec![0.0; self.h_irow.len()];
4796        let lambda = vec![1.0; self.prob.m];
4797        let x0 = self.prob.x0.clone();
4798        if !self.eval_h(
4799            Some(&x0),
4800            true,
4801            1.0,
4802            Some(&lambda),
4803            true,
4804            SparsityRequest::Values {
4805                values: &mut values,
4806            },
4807        ) {
4808            return;
4809        }
4810
4811        let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
4812        // A column whose whole pass is negligible against the Hessian as a
4813        // whole cannot move the KKT system no matter how badly its own
4814        // entries are rounded, and columns that are identically zero at
4815        // `x0` would otherwise veto on a ratio of pure noise.
4816        let h_scale = self
4817            .compressed
4818            .iter()
4819            .flat_map(|c| c.iter())
4820            .fold(0.0f64, |a, &v| a.max(v.abs()));
4821        let mut peel_veto = vec![false; self.prob.n];
4822        let mut vetoed = 0usize;
4823        for &d in &self.peeled_cols {
4824            let c = self.var_color[d as usize];
4825            if c == u32::MAX {
4826                continue;
4827            }
4828            let pass = &self.compressed[c as usize];
4829            // The floor the pass was accumulated at, against the smallest
4830            // entry actually read out of it.
4831            let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
4832            // Entries at or below the pass's own roundoff floor carry no
4833            // information to lose: `eps * scale` is the noise the pass was
4834            // accumulated at, so such an entry is already indistinguishable
4835            // from zero whether or not the column is peeled. Including them
4836            // would divide by that noise -- `orthregd` holds entries of
4837            // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
4838            // 4e-16 against an uncompressed reference.
4839            let noise = f64::EPSILON * scale;
4840            let smallest = self.decoding[c as usize]
4841                .iter()
4842                .map(|w| pass[w.row as usize].abs())
4843                .filter(|v| *v > noise)
4844                .fold(f64::INFINITY, f64::min);
4845            if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
4846                continue;
4847            }
4848            if scale <= h_scale * f64::EPSILON {
4849                continue;
4850            }
4851            let rel_err = f64::EPSILON * scale / smallest;
4852            if dbg {
4853                eprintln!(
4854                    "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
4855                     min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
4856                    if rel_err > PEEL_MAX_REL_ERR {
4857                        " VETO"
4858                    } else {
4859                        ""
4860                    }
4861                );
4862            }
4863            if rel_err > PEEL_MAX_REL_ERR {
4864                peel_veto[d as usize] = true;
4865                vetoed += 1;
4866            }
4867        }
4868
4869        if vetoed == 0 {
4870            return;
4871        }
4872        if dbg {
4873            eprintln!(
4874                "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
4875                self.peeled_cols.len()
4876            );
4877        }
4878        self.recolor(&peel_veto);
4879    }
4880
4881    /// Rebuild the coloring and everything keyed off it, barring
4882    /// `peel_veto` from the peel set.
4883    fn recolor(&mut self, peel_veto: &[bool]) {
4884        let lower_pairs: Vec<(usize, usize)> = self
4885            .h_irow
4886            .iter()
4887            .zip(&self.h_jcol)
4888            .map(|(&i, &j)| (i as usize, j as usize))
4889            .collect();
4890        let ColorTables {
4891            var_color,
4892            n_colors,
4893            peeled_cols,
4894            seeds,
4895            decoding,
4896            obj_tape_colors,
4897            con_tape_colors,
4898        } = build_color_tables(
4899            self.prob.n,
4900            self.prob.m,
4901            &lower_pairs,
4902            &self.h_tape_mask,
4903            peel_veto,
4904            &self.obj_tapes,
4905            &self.con_tapes,
4906            self.con_hybrid.as_mut(),
4907        );
4908        self.var_color = var_color;
4909        self.peeled_cols = peeled_cols;
4910        self.seeds = seeds;
4911        self.decoding = decoding;
4912        self.obj_tape_colors = obj_tape_colors;
4913        self.con_tape_colors = con_tape_colors;
4914        self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
4915    }
4916
4917    pub fn final_x(&self) -> Option<&[Number]> {
4918        self.final_x.as_deref()
4919    }
4920
4921    pub fn final_obj(&self) -> Number {
4922        self.final_obj
4923    }
4924
4925    /// Converged constraint multipliers from the last solve, in original
4926    /// `.nl` row order. `None` before a solve finishes. See
4927    /// [`Self::final_x`] for the primal counterpart.
4928    pub fn final_lambda(&self) -> Option<&[Number]> {
4929        self.final_lambda.as_deref()
4930    }
4931
4932    /// Converged lower / upper bound multipliers from the last solve, in
4933    /// original `.nl` variable order and Ipopt's internal convention (both
4934    /// `>= 0`). `None` before a solve finishes.
4935    pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
4936        Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
4937    }
4938
4939    /// The parsed problem this TNLP evaluates (bounds, starting point,
4940    /// names, suffixes). Read-only; per-instance overrides go through
4941    /// [`Self::variant`].
4942    pub fn problem(&self) -> &NlProblem {
4943        &self.prob
4944    }
4945
4946    /// Opt this model in to **curvature-based** scaling (gh #703): compute
4947    /// the per-variable and per-row factors of
4948    /// [`crate::nl_scaling::curvature_scaling`] and serve them from
4949    /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
4950    /// them through the channel it already has for user factors.
4951    ///
4952    /// Returns `false` when the model is not one the scheme is defined for
4953    /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
4954    /// exists. The caller must surface that as an error rather than solving
4955    /// unscaled: an accepted scaling option that is then quietly not applied
4956    /// is exactly the gh #483 failure.
4957    ///
4958    /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
4959    /// passes over the magnitude surrogates, and nothing at all for a solve
4960    /// that never calls it.
4961    pub fn enable_curvature_scaling(&mut self) -> bool {
4962        match crate::nl_scaling::curvature_scaling(&self.prob) {
4963            Some(sc) => {
4964                self.curvature_scaling = Some(sc);
4965                true
4966            }
4967            None => false,
4968        }
4969    }
4970
4971    /// Whether [`Self::enable_curvature_scaling`] has been called and
4972    /// succeeded.
4973    pub fn curvature_scaling_enabled(&self) -> bool {
4974        self.curvature_scaling.is_some()
4975    }
4976
4977    /// Whether the enabled curvature scaling actually read any curvature —
4978    /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
4979    /// scaling is not enabled, and `false` for a degree-≤2 model whose every
4980    /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
4981    /// equilibration of `[A b]`.
4982    pub fn curvature_scaling_read_curvature(&self) -> bool {
4983        self.curvature_scaling
4984            .as_ref()
4985            .is_some_and(|sc| sc.quadratic)
4986    }
4987
4988    /// Is constraint row `i` evaluated from a constant quadratic form
4989    /// rather than from an AD tape (gh #588, Q4)?
4990    ///
4991    /// Structural, so it answers before any evaluation. Exposed for the
4992    /// differential test, which has to know which models exercise the fast
4993    /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
4994    pub fn quadratic_row(&self, i: usize) -> bool {
4995        self.quad.row_form(i).is_some()
4996    }
4997
4998    /// As [`Self::quadratic_row`], for the objective.
4999    pub fn quadratic_objective(&self) -> bool {
5000        self.quad.objective_form().is_some()
5001    }
5002
5003    /// Structural set of variables that appear in *some* nonlinear part —
5004    /// the union of `collect_vars` over the objective and every constraint
5005    /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5006    /// list below so the two can never disagree.
5007    fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5008        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5009        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5010        for row in &self.prob.con_nonlinear {
5011            row.collect_vars(&mut nonlinear);
5012        }
5013        nonlinear
5014    }
5015
5016    /// The nonlinear-variable list published through the TNLP contract
5017    /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5018    /// ascending, in the C index style [`NlTnlp`] reports.
5019    ///
5020    /// The contract's asymmetry decides how this is computed. A consumer
5021    /// treats every variable *absent* from the list as linear — Ipopt's
5022    /// limited-memory Hessian skips the quasi-Newton update in that
5023    /// subspace — so naming too few variables is a wrong answer, while
5024    /// naming too many merely costs work. Hence:
5025    ///
5026    /// * When the `.nl` header says **every** variable is nonlinear, publish
5027    ///   that and skip the walk. This is the maximally conservative answer,
5028    ///   so it is sound whatever the header's provenance, and it is the
5029    ///   common case for the models this matters on (`eigena2`: 110 of 110).
5030    /// * Otherwise walk the trees. The header's prefix
5031    ///   (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5032    ///   answer, but it would be one that *trusts* the writer to have
5033    ///   ordered the variables as the format requires, and the walk is
5034    ///   already paid once per solve by `get_variables_linearity`. The
5035    ///   walk is also the only option for a model built through
5036    ///   [`NlProblem::from_expressions`], which has no header at all.
5037    ///
5038    /// The two disagree only in the safe direction, which
5039    /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5040    /// fixture corpus: the walked set always sits inside the header's
5041    /// prefix, because `parse_nl_text` folds constant `C` bodies away
5042    /// (`gh #492`) and AMPL's own census predates that fold.
5043    fn nonlinear_variables(&self) -> Vec<Index> {
5044        if let Some(c) = self.prob.nl_counts
5045            && c.nonlinear_vars() >= self.prob.n
5046        {
5047            return (0..self.prob.n as Index).collect();
5048        }
5049        self.nonlinear_var_set()
5050            .into_iter()
5051            .map(|i| i as Index)
5052            .collect()
5053    }
5054
5055    /// Mutable access to that same problem, for a caller that owns this
5056    /// TNLP outright.
5057    ///
5058    /// The tapes were built from the expressions in [`Self::problem`] and
5059    /// are not rebuilt, so editing an expression here does **not** change
5060    /// what this TNLP evaluates. It exists for teardown: the Python
5061    /// binding takes the expression trees out through here so a deeply
5062    /// nested one is dropped on a stack chosen for it rather than
5063    /// recursively on whatever thread collected the object (pounce#472).
5064    pub fn problem_mut(&mut self) -> &mut NlProblem {
5065        &mut self.prob
5066    }
5067
5068    /// Hessian-vector product of the Lagrangian:
5069    /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5070    ///
5071    /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5072    /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5073    /// compressed columns into the sparse lower triangle; here the seed is
5074    /// the caller's `v` directly, so it is a single forward-over-reverse
5075    /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5076    /// chromatic number. That is what makes it usable on models where
5077    /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5078    /// truncated-CG step only ever needs `∇²L · v`.
5079    ///
5080    /// Sign convention matches `eval_h` and the rest of this evaluator: a
5081    /// `maximize` model's objective is negated so the returned operator is
5082    /// the one that minimizing solves. `lambda` is `None` for the objective
5083    /// block alone.
5084    ///
5085    /// `out` is overwritten (not accumulated into). Errors on any length
5086    /// mismatch rather than panicking, since the Python binding hands this
5087    /// arbitrary user arrays.
5088    pub fn hessian_vector_product(
5089        &mut self,
5090        x: &[Number],
5091        v: &[Number],
5092        obj_factor: Number,
5093        lambda: Option<&[Number]>,
5094        out: &mut [Number],
5095    ) -> Result<(), String> {
5096        self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5097    }
5098
5099    /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5100    /// once, `out[:, c] = ∇²L · v[:, c]`.
5101    ///
5102    /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5103    /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5104    ///
5105    /// Worth having as its own entry point rather than a loop over the
5106    /// single-vector call: the forward sweep depends only on `x`, so a block
5107    /// runs it *once per tape* and reuses `vals` across all `k` directions,
5108    /// where `k` separate calls would redo it `k` times. Only the
5109    /// forward-tangent + reverse-over-tangent passes are per-direction. That
5110    /// is the shape a block-Krylov solve, a directional-derivative probe, or
5111    /// a densify-the-Hessian loop wants.
5112    ///
5113    /// An all-zero direction is skipped, so passing a sparse block whose
5114    /// columns are mostly empty costs only the columns that carry signal.
5115    /// (The sparsity that dominates is the model's own: each tape touches
5116    /// only its own variables, and `hessian_directional` is O(tape ops), not
5117    /// O(n).)
5118    pub fn hessian_vector_products(
5119        &mut self,
5120        x: &[Number],
5121        v: &[Number],
5122        k: usize,
5123        obj_factor: Number,
5124        lambda: Option<&[Number]>,
5125        out: &mut [Number],
5126    ) -> Result<(), String> {
5127        let (n, m) = (self.prob.n, self.prob.m);
5128        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5129            if got == want {
5130                Ok(())
5131            } else {
5132                Err(format!(
5133                    "hessian_vector_product: {name} has length {got}, expected {want}"
5134                ))
5135            }
5136        };
5137        check("x", x.len(), n)?;
5138        check("v", v.len(), n * k)?;
5139        check("out", out.len(), n * k)?;
5140        if let Some(lam) = lambda {
5141            check("lambda", lam.len(), m)?;
5142        }
5143
5144        out.fill(0.0);
5145        if k == 0 || n == 0 {
5146            return Ok(());
5147        }
5148
5149        // Which directions carry signal. Computed once, not per (tape,
5150        // direction) pair — with many small summand tapes the scan would
5151        // otherwise dominate the work it is meant to save. Reuses the
5152        // persistent mask so a Krylov loop allocates nothing per iteration.
5153        self.hvp_live.clear();
5154        self.hvp_live
5155            .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5156        if !self.hvp_live.iter().any(|&l| l) {
5157            return Ok(());
5158        }
5159
5160        let obj_seed = if self.prob.minimize {
5161            obj_factor
5162        } else {
5163            -obj_factor
5164        };
5165        // The constant blocks. `H · v` for a quadratic form is a matvec
5166        // against stored values, so unlike a tape it needs no forward sweep
5167        // and no `x` at all. Easy to forget — a matrix-free solve that
5168        // silently dropped the quadratic part of `∇²L` would still converge,
5169        // just to a different point by a different route, which is the
5170        // failure mode this series is most exposed to.
5171        if !self.quad.is_empty() {
5172            if obj_seed != 0.0 {
5173                if let Some(f) = self.quad.objective_form() {
5174                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5175                        if self.hvp_live[c] {
5176                            self.quad.add_hessian_vector(
5177                                f,
5178                                &v[c * n..(c + 1) * n],
5179                                obj_seed,
5180                                out_col,
5181                            );
5182                        }
5183                    }
5184                }
5185            }
5186            if let Some(lam) = lambda {
5187                for (i, &w) in lam.iter().enumerate() {
5188                    if w == 0.0 {
5189                        continue;
5190                    }
5191                    let Some(f) = self.quad.row_form(i) else {
5192                        continue;
5193                    };
5194                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5195                        if self.hvp_live[c] {
5196                            self.quad
5197                                .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5198                        }
5199                    }
5200                }
5201            }
5202        }
5203
5204        if obj_seed != 0.0 {
5205            for t in &self.obj_tapes {
5206                if t.ops.is_empty() {
5207                    continue;
5208                }
5209                // Once per tape, not once per direction — the whole point
5210                // of the block form.
5211                t.forward_into(x, &mut self.vals_scratch);
5212                for (c, out_col) in out.chunks_mut(n).enumerate() {
5213                    if !self.hvp_live[c] {
5214                        continue;
5215                    }
5216                    t.hessian_directional(
5217                        &self.vals_scratch,
5218                        &v[c * n..(c + 1) * n],
5219                        obj_seed,
5220                        out_col,
5221                        &mut self.dot_scratch,
5222                        &mut self.adj_scratch,
5223                        &mut self.adj_dot_scratch,
5224                    );
5225                }
5226            }
5227        }
5228
5229        if let Some(lam) = lambda {
5230            // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5231            // range for every k.
5232            for (i, &w) in lam.iter().enumerate() {
5233                if w == 0.0 {
5234                    continue;
5235                }
5236                for t in &self.con_tapes[i] {
5237                    if t.ops.is_empty() {
5238                        continue;
5239                    }
5240                    t.forward_into(x, &mut self.vals_scratch);
5241                    for (c, out_col) in out.chunks_mut(n).enumerate() {
5242                        if !self.hvp_live[c] {
5243                            continue;
5244                        }
5245                        t.hessian_directional(
5246                            &self.vals_scratch,
5247                            &v[c * n..(c + 1) * n],
5248                            w,
5249                            out_col,
5250                            &mut self.dot_scratch,
5251                            &mut self.adj_scratch,
5252                            &mut self.adj_dot_scratch,
5253                        );
5254                    }
5255                }
5256            }
5257        }
5258
5259        Ok(())
5260    }
5261
5262    /// Clone this TNLP with per-instance overrides applied — the
5263    /// "one structure, many bound / starting-point variations" case of
5264    /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5265    /// coloring are reused via `Clone` (they depend only on the model
5266    /// structure, which a variation cannot change); only the values in
5267    /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5268    /// are replaced. Any stale `final_x` from a previous solve of
5269    /// `self` is cleared on the clone.
5270    ///
5271    /// Errors when an override's length does not match the model
5272    /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5273    pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5274        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5275            if got == want {
5276                Ok(())
5277            } else {
5278                Err(format!(
5279                    "NlVariation.{name} has length {got}, expected {want}"
5280                ))
5281            }
5282        };
5283        let mut out = self.clone();
5284        out.final_x = None;
5285        out.final_obj = 0.0;
5286        out.final_lambda = None;
5287        out.final_z_l = None;
5288        out.final_z_u = None;
5289        if let Some(x0) = &v.x0 {
5290            check("x0", x0.len(), self.prob.n)?;
5291            out.prob.x0.clone_from(x0);
5292        }
5293        if let Some(x_l) = &v.x_l {
5294            check("x_l", x_l.len(), self.prob.n)?;
5295            out.prob.x_l.clone_from(x_l);
5296        }
5297        if let Some(x_u) = &v.x_u {
5298            check("x_u", x_u.len(), self.prob.n)?;
5299            out.prob.x_u.clone_from(x_u);
5300        }
5301        if let Some(g_l) = &v.g_l {
5302            check("g_l", g_l.len(), self.prob.m)?;
5303            out.prob.g_l.clone_from(g_l);
5304        }
5305        if let Some(g_u) = &v.g_u {
5306            check("g_u", g_u.len(), self.prob.m)?;
5307            out.prob.g_u.clone_from(g_u);
5308        }
5309        Ok(out)
5310    }
5311
5312    /// Build one [`NlTnlp`] per variation, sharing this instance's
5313    /// structure (see [`Self::variant`]). Returns instances in input
5314    /// order; errors on the first length-mismatched variation.
5315    pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5316        vs.iter().map(|v| self.variant(v)).collect()
5317    }
5318}
5319
5320/// Per-instance overrides for building a family of related NLP
5321/// instances from one parsed `.nl` model (pounce#126): same structure
5322/// and tapes, different starting point and/or bounds — parametric
5323/// sweeps, multi-start, or branch-and-bound node relaxations where
5324/// each node only tightens variable bounds. `None` keeps the base
5325/// model's value.
5326#[derive(Debug, Clone, Default)]
5327pub struct NlVariation {
5328    pub x0: Option<Vec<Number>>,
5329    pub x_l: Option<Vec<Number>>,
5330    pub x_u: Option<Vec<Number>>,
5331    pub g_l: Option<Vec<Number>>,
5332    pub g_u: Option<Vec<Number>>,
5333}
5334
5335impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5336    /// Per-`.nl`-row constraint expression tape, with the linear
5337    /// part folded in. Returns `None` for constraints that contribute
5338    /// neither a nonlinear expression nor any linear coefficients
5339    /// (so FBBT skips them — there's nothing to tighten).
5340    fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5341        if i >= self.prob.con_nonlinear.len() {
5342            return None;
5343        }
5344        let nonlinear = self.prob.con_expr(i);
5345        let linear = self
5346            .prob
5347            .con_linear
5348            .get(i)
5349            .map(|v| v.as_slice())
5350            .unwrap_or(&[]);
5351        // FBBT needs the tree, so a recognized body is rebuilt here. It is
5352        // rebuilt once per row per presolve pass and dropped again, so the
5353        // DAG never all exists at once — which is the property the phase is
5354        // actually about. Translating the stored coefficients instead would
5355        // propagate bounds through a different association and change the
5356        // tightening; that is not a trade this phase makes.
5357        crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
5358    }
5359
5360    /// Variable name from the sibling `.col` file, if one was loaded.
5361    /// Index is original `.nl` column order.
5362    fn variable_name(&self, i: usize) -> Option<&str> {
5363        self.prob.var_names.get(i).map(String::as_str)
5364    }
5365
5366    /// Constraint name from the sibling `.row` file, if one was loaded.
5367    /// Index is original `.nl` row order.
5368    fn constraint_name(&self, i: usize) -> Option<&str> {
5369        self.prob.con_names.get(i).map(String::as_str)
5370    }
5371}
5372
5373impl TNLP for NlTnlp {
5374    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
5375        Some(NlpInfo {
5376            n: self.prob.n as Index,
5377            m: self.prob.m as Index,
5378            nnz_jac_g: self.jac_nnz as Index,
5379            nnz_h_lag: self.h_irow.len() as Index,
5380            index_style: IndexStyle::C,
5381        })
5382    }
5383
5384    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
5385        b.x_l.copy_from_slice(&self.prob.x_l);
5386        b.x_u.copy_from_slice(&self.prob.x_u);
5387        if !self.prob.g_l.is_empty() {
5388            b.g_l.copy_from_slice(&self.prob.g_l);
5389            b.g_u.copy_from_slice(&self.prob.g_u);
5390        }
5391        true
5392    }
5393
5394    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
5395        sp.x.copy_from_slice(&self.prob.x0);
5396        // The `.nl` `d` segment supplies initial constraint multipliers
5397        // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
5398        // the engine when `warm_start_init_point yes` — by handing them
5399        // back; `OrigIpoptNlp::get_starting_point` then compresses them into
5400        // the algorithm-side y_c / y_d. Without this the warm start silently
5401        // began from zero multipliers, discarding the parsed duals. (Code
5402        // review 2026-06 item M19.) The `.nl` `d` segment carries no bound
5403        // multipliers, so `z_l`/`z_u` are left to the engine's defaults.
5404        if sp.init_lambda {
5405            sp.lambda.copy_from_slice(&self.prob.lambda0);
5406        }
5407        true
5408    }
5409
5410    /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
5411    /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
5412    /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
5413    /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
5414    /// writes into. Before gh#483 nothing implemented this callback for
5415    /// `.nl` input, so a tagged model reached the solver with the option
5416    /// accepted and *no* scaling applied, silently.
5417    ///
5418    /// Returns `false` (engine falls back to no scaling) when the file
5419    /// declares no `scaling_factor` suffix at all — the same "user
5420    /// supplied nothing" answer as the default `TNLP` impl.
5421    ///
5422    /// AMPL suffix vectors default to **0** for components the model did
5423    /// not tag, and 0 is not a usable scale factor. A zero entry is
5424    /// therefore read as "not tagged" and becomes 1.0, which is what
5425    /// "unlisted components are unscaled" means. Per-variable factors
5426    /// are passed straight through: `OrigIpoptNlp` does not model them
5427    /// and refuses the solve with a message rather than dropping them.
5428    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
5429        const NAME: &str = "scaling_factor";
5430        let sfx = &self.prob.suffixes;
5431        let obj = sfx.obj_real.get(NAME);
5432        let var = sfx.var_real.get(NAME);
5433        let con = sfx.con_real.get(NAME);
5434        let computed = self.curvature_scaling.as_ref();
5435        if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
5436            return false;
5437        }
5438        // Curvature-based factors (gh #703) are the *base*; a
5439        // `scaling_factor` suffix the model actually carries overrides
5440        // them component by component, because an explicit factor from the
5441        // modeller beats one inferred from the coefficients. A model with
5442        // no suffixes is the ordinary case and gets the computed vectors
5443        // whole.
5444        if let Some(sc) = computed {
5445            // The length guards are a `copy_from_slice` panic guard, not a
5446            // policy: `curvature_scaling` sizes both vectors from the same
5447            // `NlProblem` this callback is answering for, so a mismatch is a
5448            // bug upstream, not a model the scheme declines. Declining is the
5449            // *worst* available response to it — `use_*_scaling` stays false,
5450            // the engine reads that as "user supplied nothing", and the run
5451            // proceeds unscaled with the option accepted, which is gh #483
5452            // again. Assert it in debug builds so a mismatch is found here,
5453            // where it is one line, instead of as a slow solve later.
5454            debug_assert_eq!(
5455                sc.x.len(),
5456                req.x_scaling.len(),
5457                "curvature x-scaling sized {} for a {}-variable request",
5458                sc.x.len(),
5459                req.x_scaling.len()
5460            );
5461            debug_assert_eq!(
5462                sc.g.len(),
5463                req.g_scaling.len(),
5464                "curvature g-scaling sized {} for a {}-row request",
5465                sc.g.len(),
5466                req.g_scaling.len()
5467            );
5468            if sc.x.len() == req.x_scaling.len() {
5469                req.x_scaling.copy_from_slice(&sc.x);
5470                *req.use_x_scaling = true;
5471            }
5472            if sc.g.len() == req.g_scaling.len() {
5473                req.g_scaling.copy_from_slice(&sc.g);
5474                *req.use_g_scaling = true;
5475            }
5476        }
5477        // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
5478        // are parsed and ignored), so its entry is the objective scale.
5479        *req.obj_scaling = obj
5480            .and_then(|v| v.first().copied())
5481            .filter(|&s| s != 0.0)
5482            .unwrap_or(1.0);
5483        // A zero entry is AMPL's "untagged" default, not a scale factor.
5484        // An untagged component therefore falls back to the base: the
5485        // curvature factor when one was computed, and an explicit 1.0
5486        // otherwise — explicit because the callback's contract is to fill
5487        // the buffer, not to assume the caller pre-filled it with ones.
5488        if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
5489            for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
5490                if s != 0.0 {
5491                    *slot = s;
5492                } else if computed.is_none() {
5493                    *slot = 1.0;
5494                }
5495            }
5496            *req.use_x_scaling = true;
5497        } else if computed.is_none() {
5498            *req.use_x_scaling = false;
5499        }
5500        if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
5501            for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
5502                if s != 0.0 {
5503                    *slot = s;
5504                } else if computed.is_none() {
5505                    *slot = 1.0;
5506                }
5507            }
5508            *req.use_g_scaling = true;
5509        } else if computed.is_none() {
5510            *req.use_g_scaling = false;
5511        }
5512        true
5513    }
5514
5515    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
5516        // Reuse the shared forward-value arena (sized to `max_tape_n`) so
5517        // each summand sweep allocates nothing — see `Tape::eval_into`.
5518        let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
5519        let mut nl: Number = 0.0;
5520        for t in obj_tapes {
5521            nl += t.eval_into(x, vals);
5522        }
5523        // A recognized objective has no tapes, so the loop above added
5524        // nothing and the form supplies the whole nonlinear part — including
5525        // the linear and constant terms AMPL folded into that tree, which is
5526        // why they are *not* also in `obj_linear` / `obj_constant`.
5527        if let Some(f) = self.quad.objective_form() {
5528            nl += self.quad.value(f, x);
5529        }
5530        let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
5531        let v = self.prob.obj_constant + nl + lin;
5532        let signed = if self.prob.minimize { v } else { -v };
5533        Some(signed)
5534    }
5535
5536    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
5537        grad.fill(0.0);
5538        // Reuse the forward-value / adjoint scratch arenas (sized to
5539        // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
5540        // nothing — see `Tape::gradient_seed_into` (M18).
5541        for t in &self.obj_tapes {
5542            t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
5543        }
5544        if let Some(f) = self.quad.objective_form() {
5545            self.quad.add_gradient(f, x, 1.0, grad);
5546        }
5547        for (i, c) in &self.prob.obj_linear {
5548            grad[*i] += c;
5549        }
5550        if !self.prob.minimize {
5551            for g in grad.iter_mut() {
5552                *g = -*g;
5553            }
5554        }
5555        true
5556    }
5557
5558    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
5559        // Constraint values are the line search's inner loop: on a
5560        // constraint-heavy model (m >> n) this runs ~10x per iteration over
5561        // every summand tape in the problem. Reuse the shared forward-value
5562        // arena so the sweep allocates nothing — the per-summand `Vec` the
5563        // allocating `Tape::eval` used to build was ~20% of `eval_g` on
5564        // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
5565        // `Tape::eval_into`.
5566        let m = self.prob.m;
5567        let con_linear = &self.prob.con_linear;
5568        let quad = &self.quad;
5569        if let Some(h) = &mut self.con_hybrid {
5570            // Shared CSE bodies once for the whole constraint block, then one
5571            // local sweep per summand (pounce#476).
5572            let ConHybrid {
5573                tape,
5574                row_start,
5575                prelude_vals,
5576                local_vals,
5577                ..
5578            } = h;
5579            tape.forward_prelude(x, prelude_vals);
5580            for i in 0..m {
5581                let mut nl: Number = 0.0;
5582                for s in &tape.summands[row_start[i]..row_start[i + 1]] {
5583                    tape.forward_summand(s, x, prelude_vals, local_vals);
5584                    nl += tape.root_value(s, local_vals);
5585                }
5586                if let Some(f) = quad.row_form(i) {
5587                    nl += quad.value(f, x);
5588                }
5589                let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
5590                g[i] = nl + lin;
5591            }
5592            return true;
5593        }
5594        let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
5595        for i in 0..m {
5596            let mut nl: Number = 0.0;
5597            for t in &con_tapes[i] {
5598                nl += t.eval_into(x, vals);
5599            }
5600            // A quadratic row's summand range is empty above, so this is its
5601            // whole nonlinear part: one matvec against a constant matrix in
5602            // place of a walk over every monomial's tape.
5603            if let Some(f) = quad.row_form(i) {
5604                nl += quad.value(f, x);
5605            }
5606            let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
5607            g[i] = nl + lin;
5608        }
5609        true
5610    }
5611
5612    fn eval_jac_g(
5613        &mut self,
5614        x: Option<&[Number]>,
5615        _new_x: bool,
5616        mode: SparsityRequest<'_>,
5617    ) -> bool {
5618        match mode {
5619            SparsityRequest::Structure { irow, jcol } => {
5620                let mut k = 0;
5621                for i in 0..self.prob.m {
5622                    for &j in &self.jac_cols[i] {
5623                        irow[k] = i as Index;
5624                        jcol[k] = j as Index;
5625                        k += 1;
5626                    }
5627                }
5628                true
5629            }
5630            SparsityRequest::Values { values } => {
5631                let n = self.prob.n;
5632                if self.scratch_row_grad.len() < n {
5633                    self.scratch_row_grad.resize(n, 0.0);
5634                }
5635                let Self {
5636                    prob,
5637                    con_tapes,
5638                    con_hybrid,
5639                    quad,
5640                    jac_cols,
5641                    scratch_row_grad,
5642                    vals_scratch,
5643                    adj_scratch,
5644                    ..
5645                } = self;
5646                let xs = x.unwrap_or(&prob.x0);
5647                let mut k = 0;
5648                // Shared-CSE path: the forward sweep over the CSE bodies runs
5649                // once for the whole constraint block instead of once per
5650                // referencing summand. The reverse sweep cannot be shared —
5651                // each row needs its own gradient — so a summand still walks
5652                // its own `prelude_reach` backwards.
5653                if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
5654                    let ConHybrid {
5655                        tape,
5656                        row_start,
5657                        prelude_vals,
5658                        local_vals,
5659                        local_adj,
5660                        prelude_adj,
5661                        ..
5662                    } = h;
5663                    tape.forward_prelude(xs, prelude_vals);
5664                    for i in 0..prob.m {
5665                        for &j in &jac_cols[i] {
5666                            scratch_row_grad[j] = 0.0;
5667                        }
5668                        for s in &tape.summands[row_start[i]..row_start[i + 1]] {
5669                            tape.forward_summand(s, xs, prelude_vals, local_vals);
5670                            tape.gradient_summand(
5671                                s,
5672                                prelude_vals,
5673                                local_vals,
5674                                1.0,
5675                                scratch_row_grad,
5676                                local_adj,
5677                                prelude_adj,
5678                            );
5679                        }
5680                        if let Some(f) = quad.row_form(i) {
5681                            quad.add_gradient(f, xs, 1.0, scratch_row_grad);
5682                        }
5683                        for &(v, c) in &prob.con_linear[i] {
5684                            scratch_row_grad[v] += c;
5685                        }
5686                        for &j in &jac_cols[i] {
5687                            values[k] = scratch_row_grad[j];
5688                            k += 1;
5689                        }
5690                    }
5691                    return true;
5692                }
5693                for i in 0..prob.m {
5694                    for &j in &jac_cols[i] {
5695                        scratch_row_grad[j] = 0.0;
5696                    }
5697                    for t in &con_tapes[i] {
5698                        // Allocation-free reverse-AD per summand tape (M18):
5699                        // reuse the shared forward/adjoint scratch arenas.
5700                        t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
5701                    }
5702                    // `Hx + a` for a quadratic row: one matvec over the
5703                    // row's support, in place of a reverse sweep per
5704                    // monomial. `jac_cols[i]` already covers the form's
5705                    // gradient support, so the scatter lands inside the
5706                    // window zeroed above.
5707                    if let Some(f) = quad.row_form(i) {
5708                        quad.add_gradient(f, xs, 1.0, scratch_row_grad);
5709                    }
5710                    for &(v, c) in &prob.con_linear[i] {
5711                        scratch_row_grad[v] += c;
5712                    }
5713                    for &j in &jac_cols[i] {
5714                        values[k] = scratch_row_grad[j];
5715                        k += 1;
5716                    }
5717                }
5718                true
5719            }
5720        }
5721    }
5722
5723    fn eval_h(
5724        &mut self,
5725        x: Option<&[Number]>,
5726        _new_x: bool,
5727        obj_factor: Number,
5728        lambda: Option<&[Number]>,
5729        _new_lambda: bool,
5730        mode: SparsityRequest<'_>,
5731    ) -> bool {
5732        match mode {
5733            SparsityRequest::Structure { irow, jcol } => {
5734                irow.copy_from_slice(&self.h_irow);
5735                jcol.copy_from_slice(&self.h_jcol);
5736                true
5737            }
5738            SparsityRequest::Values { values } => {
5739                let x = x.unwrap_or(&self.prob.x0);
5740                values.fill(0.0);
5741
5742                let obj_seed = if self.prob.minimize {
5743                    obj_factor
5744                } else {
5745                    -obj_factor
5746                };
5747                // Coloring path. For each (tape, weight) we do
5748                // one forward pass into `vals_scratch`, then one
5749                // forward-tangent+reverse-over-tangent per color
5750                // touched by that tape. Each pass accumulates a
5751                // weighted contribution of (H_tape · seed_c) into
5752                // `compressed[c]`. After all tapes done, we
5753                // decode each color's compressed vector into the
5754                // sparse `values` array.
5755                for buf in &mut self.compressed {
5756                    buf.fill(0.0);
5757                }
5758
5759                // The constant blocks first: no forward sweep, no
5760                // directional product, no decode — the multipliers are the
5761                // only thing that changed since the model was read, so this
5762                // is one `values[slot] += w · h` pass per live form. Skipped
5763                // wholesale on a model with nothing recognized, so such a
5764                // model does not pay an `O(m)` scan for a structure that is
5765                // empty.
5766                if !self.quad.is_empty() {
5767                    if obj_seed != 0.0 {
5768                        if let Some(f) = self.quad.objective_form() {
5769                            self.quad.accumulate_hessian(f, obj_seed, values);
5770                        }
5771                    }
5772                    if let Some(lam) = lambda {
5773                        for i in 0..self.prob.m {
5774                            let w = lam[i];
5775                            if w == 0.0 {
5776                                continue;
5777                            }
5778                            if let Some(f) = self.quad.row_form(i) {
5779                                self.quad.accumulate_hessian(f, w, values);
5780                            }
5781                        }
5782                    }
5783                }
5784
5785                if obj_seed != 0.0 {
5786                    for (ti, t) in self.obj_tapes.iter().enumerate() {
5787                        if t.ops.is_empty() {
5788                            continue;
5789                        }
5790                        t.forward_into(x, &mut self.vals_scratch);
5791                        for &c in &self.obj_tape_colors[ti] {
5792                            t.hessian_directional(
5793                                &self.vals_scratch,
5794                                &self.seeds[c as usize],
5795                                obj_seed,
5796                                &mut self.compressed[c as usize],
5797                                &mut self.dot_scratch,
5798                                &mut self.adj_scratch,
5799                                &mut self.adj_dot_scratch,
5800                            );
5801                        }
5802                    }
5803                }
5804
5805                match (lambda, self.con_hybrid.as_mut()) {
5806                    // Shared-CSE path (issue #557). Per color the prelude's
5807                    // second-order work runs ONCE for the whole constraint
5808                    // block: one forward tangent, then — because
5809                    // reverse-over-tangent is linear in its adjoint seeds —
5810                    // one unit-weight reverse sweep over the λ-weighted
5811                    // adjoints accumulated by every summand of that color.
5812                    // The flat path below repeats both sweeps over the
5813                    // inlined CSE body once per referencing summand.
5814                    (Some(lam), Some(h)) if h.use_for_hess => {
5815                        let ConHybrid {
5816                            tape,
5817                            prelude_vals,
5818                            local_vals_all,
5819                            local_off,
5820                            summand_row,
5821                            hess_color_summands,
5822                            hess_color_reach,
5823                            hess_color_reach_off,
5824                            prelude_dot,
5825                            hess_prelude_adj,
5826                            prelude_adj_dot,
5827                            local_dot,
5828                            local_adj,
5829                            local_adj_dot,
5830                            ..
5831                        } = h;
5832                        // Forward once (values are color-independent):
5833                        // prelude for the block, then each summand of a row
5834                        // with a live multiplier into its packed slice.
5835                        tape.forward_prelude(x, prelude_vals);
5836                        for (si, s) in tape.summands.iter().enumerate() {
5837                            if lam[summand_row[si] as usize] == 0.0 {
5838                                continue;
5839                            }
5840                            tape.forward_summand(
5841                                s,
5842                                x,
5843                                prelude_vals,
5844                                &mut local_vals_all[local_off[si]..local_off[si + 1]],
5845                            );
5846                        }
5847                        for (c, list) in hess_color_summands.iter().enumerate() {
5848                            if !list
5849                                .iter()
5850                                .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
5851                            {
5852                                continue;
5853                            }
5854                            let seed = &self.seeds[c];
5855                            let out = &mut self.compressed[c];
5856                            let creach = &hess_color_reach
5857                                [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
5858                            tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
5859                            for &si in list {
5860                                let si = si as usize;
5861                                let w = lam[summand_row[si] as usize];
5862                                if w == 0.0 {
5863                                    continue;
5864                                }
5865                                tape.hessian_summand_directional(
5866                                    &tape.summands[si],
5867                                    &local_vals_all[local_off[si]..local_off[si + 1]],
5868                                    prelude_dot,
5869                                    seed,
5870                                    w,
5871                                    out,
5872                                    local_dot,
5873                                    local_adj,
5874                                    local_adj_dot,
5875                                    hess_prelude_adj,
5876                                    prelude_adj_dot,
5877                                );
5878                            }
5879                            tape.prelude_reverse_directional(
5880                                prelude_vals,
5881                                prelude_dot,
5882                                creach,
5883                                out,
5884                                hess_prelude_adj,
5885                                prelude_adj_dot,
5886                            );
5887                        }
5888                    }
5889                    (Some(lam), _) => {
5890                        for k in 0..self.prob.m {
5891                            let w = lam[k];
5892                            if w == 0.0 {
5893                                continue;
5894                            }
5895                            for (ti, t) in self.con_tapes[k].iter().enumerate() {
5896                                if t.ops.is_empty() {
5897                                    continue;
5898                                }
5899                                t.forward_into(x, &mut self.vals_scratch);
5900                                for &c in &self.con_tape_colors[k][ti] {
5901                                    t.hessian_directional(
5902                                        &self.vals_scratch,
5903                                        &self.seeds[c as usize],
5904                                        w,
5905                                        &mut self.compressed[c as usize],
5906                                        &mut self.dot_scratch,
5907                                        &mut self.adj_scratch,
5908                                        &mut self.adj_dot_scratch,
5909                                    );
5910                                }
5911                            }
5912                        }
5913                    }
5914                    (None, _) => {}
5915                }
5916
5917                // Decode each color's compressed Hessian-vector
5918                // result into the lower-triangle `values` array.
5919                for (c, table) in self.decoding.iter().enumerate() {
5920                    let comp = &self.compressed[c];
5921                    for w in table {
5922                        values[w.hess_idx as usize] += comp[w.row as usize];
5923                    }
5924                }
5925                true
5926            }
5927        }
5928    }
5929
5930    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
5931        self.final_x = Some(sol.x.to_vec());
5932        self.final_obj = sol.obj_value;
5933        self.final_lambda = Some(sol.lambda.to_vec());
5934        self.final_z_l = Some(sol.z_l.to_vec());
5935        self.final_z_u = Some(sol.z_u.to_vec());
5936    }
5937
5938    /// Publish the `.col` / `.row` names (captured at load time) under the
5939    /// conventional `idx_names` metadata key, in original `.nl` order. The
5940    /// adapter permutes these into split space (see
5941    /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
5942    /// near-singular Jacobian row as the `mass_balance` equation rather
5943    /// than "row 3" — the model-vs-index gap Lee et al. (2024,
5944    /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
5945    /// model debugging. Declines (returns false) when the model shipped no
5946    /// name files so callers fall back to index labels.
5947    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
5948        let mut any = false;
5949        if !self.prob.var_names.is_empty() {
5950            var.strings
5951                .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
5952            any = true;
5953        }
5954        if !self.prob.con_names.is_empty() {
5955            con.strings
5956                .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
5957            any = true;
5958        }
5959        any
5960    }
5961
5962    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
5963        // A row is linear iff its nonlinear-part expression is the
5964        // identity zero — either left over from initial allocation ("no
5965        // `C<idx>` segment touched this row") or installed by the
5966        // constant-row-body fold in `parse_nl_text`, which shifts a
5967        // variable-free `C<idx>` body into the row bounds precisely so
5968        // that this test is a genuine linearity test and not just an
5969        // identity check (`gh #492`).
5970        for (i, t) in types.iter_mut().enumerate() {
5971            *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
5972                Linearity::Linear
5973            } else {
5974                Linearity::NonLinear
5975            };
5976        }
5977        true
5978    }
5979
5980    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
5981        // Global linearity, per the upstream TNLP contract: a variable is
5982        // NonLinear iff it appears in the nonlinear part of the objective
5983        // or of any constraint; otherwise Linear. The parsed `.nl` splits
5984        // every row into a linear part (J/G coefficient list) and a
5985        // nonlinear expression, so the set of nonlinear variables is
5986        // exactly the structural union of `collect_vars` over
5987        // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
5988        // only by a linear part — or not referenced at all — is Linear.
5989        //
5990        let nonlinear = self.nonlinear_var_set();
5991        for (i, t) in types.iter_mut().enumerate() {
5992            *t = if nonlinear.contains(&i) {
5993                Linearity::NonLinear
5994            } else {
5995                Linearity::Linear
5996            };
5997        }
5998        true
5999    }
6000
6001    fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6002        // Objective-scoped variant of `get_variables_linearity`: only
6003        // `obj_nonlinear` contributes. This is what engages the presolve
6004        // auxiliary-elimination safeguard (pounce-presolve H11): a variable
6005        // that is nonlinear in the objective but happens to have a zero
6006        // gradient at the single probe point (e.g. `f = (x - x0)^2`
6007        // warm-started at `x0`) is kept in the objective support instead of
6008        // being mis-classified objective-free and eliminated. A variable
6009        // that is nonlinear only in *constraints* stays `Linear` here, so
6010        // the guard does not block legitimate eliminations of
6011        // objective-free equality blocks (the gas-network case).
6012        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
6013        self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
6014        for (i, t) in types.iter_mut().enumerate() {
6015            *t = if nonlinear.contains(&i) {
6016                Linearity::NonLinear
6017            } else {
6018                Linearity::Linear
6019            };
6020        }
6021        true
6022    }
6023
6024    fn get_number_of_nonlinear_variables(&mut self) -> Index {
6025        self.nonlinear_variables().len() as Index
6026    }
6027
6028    fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
6029        let list = self.nonlinear_variables();
6030        if pos_nonlin_vars.len() < list.len() {
6031            return false;
6032        }
6033        pos_nonlin_vars[..list.len()].copy_from_slice(&list);
6034        true
6035    }
6036
6037    fn derivative_proofs(&mut self) -> DerivativeProofs {
6038        // Degree is the whole argument (gh #588, Q6). A body proved
6039        // degree ≤ 1 has a constant gradient and no second derivative; a
6040        // body proved degree 2 has a nonzero second derivative, hence a
6041        // gradient that moves. A body the recognizer refuses is
6042        // `Unknown` — and `Unknown` must stay `Unknown`, because the
6043        // refusal is structural, not a finding of nonlinearity.
6044        fn proof(affine: Option<bool>) -> DerivativeProof {
6045            match affine {
6046                Some(true) => DerivativeProof::Constant,
6047                Some(false) => DerivativeProof::Varying,
6048                None => DerivativeProof::Unknown,
6049            }
6050        }
6051        let obj_affine = self.prob.obj_nonlinear.provably_affine();
6052        let jac: Vec<DerivativeProof> = self
6053            .prob
6054            .con_nonlinear
6055            .iter()
6056            .map(|b| proof(b.provably_affine()))
6057            .collect();
6058
6059        // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
6060        // makes `∇²L` a non-constant function of `λ` — this is the QCQP
6061        // case, and it is exactly the assertion Ipopt would honour and
6062        // get wrong (§4 Lever 2). Otherwise every row must be *proved*
6063        // affine and the objective *proved* degree ≤ 2, at which point
6064        // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
6065        // reuse on `σ` because the restoration phase passes a different
6066        // one.
6067        let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
6068            DerivativeProof::Varying
6069        } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
6070            DerivativeProof::Constant
6071        } else {
6072            DerivativeProof::Unknown
6073        };
6074
6075        DerivativeProofs {
6076            grad_f: proof(obj_affine),
6077            hessian,
6078            jac,
6079        }
6080    }
6081}
6082
6083/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
6084pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
6085    let prob = read_nl_file(path)?;
6086    Ok(Rc::new(RefCell::new(NlTnlp::new(prob))))
6087}
6088
6089#[cfg(test)]
6090mod tests {
6091    use super::*;
6092
6093    /// Compile-time guarantee for the batched-solve path (pounce#126):
6094    /// a parsed problem and the TNLP built from it must be movable to a
6095    /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
6096    /// `!Send` state) into the `Expr` DAG / tape pipeline.
6097    #[test]
6098    fn nl_problem_and_tnlp_are_send() {
6099        fn assert_send<T: Send>() {}
6100        assert_send::<NlProblem>();
6101        assert_send::<NlTnlp>();
6102        assert_send::<Expr>();
6103    }
6104
6105    /// `variant()` patches starting point / bounds on a clone and
6106    /// validates override lengths; the base instance is untouched.
6107    #[test]
6108    fn variant_overrides_bounds_and_x0() {
6109        let p = parse_nl_text(SIMPLE).expect("parse");
6110        let base = NlTnlp::new(p);
6111        let var = base
6112            .variant(&NlVariation {
6113                x0: Some(vec![3.0, 4.0]),
6114                x_l: Some(vec![-1.0, -2.0]),
6115                x_u: Some(vec![5.0, 6.0]),
6116                ..Default::default()
6117            })
6118            .expect("variant");
6119        let mut var = var;
6120        let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
6121        let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
6122        assert!(var.get_bounds_info(BoundsInfo {
6123            x_l: &mut x_l,
6124            x_u: &mut x_u,
6125            g_l: &mut g_l,
6126            g_u: &mut g_u,
6127        }));
6128        assert_eq!(x_l, [-1.0, -2.0]);
6129        assert_eq!(x_u, [5.0, 6.0]);
6130        let mut x = [0.0; 2];
6131        let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
6132        assert!(var.get_starting_point(StartingPoint {
6133            init_x: true,
6134            x: &mut x,
6135            init_z: false,
6136            z_l: &mut zl,
6137            z_u: &mut zu,
6138            init_lambda: false,
6139            lambda: &mut lam,
6140        }));
6141        assert_eq!(x, [3.0, 4.0]);
6142        // Base keeps its parsed (free) bounds.
6143        assert!(base.problem().x_l[0] < -1.0e18);
6144        // Length mismatch is an error, not a panic.
6145        assert!(
6146            base.variant(&NlVariation {
6147                x0: Some(vec![1.0]),
6148                ..Default::default()
6149            })
6150            .is_err()
6151        );
6152    }
6153
6154    /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
6155    /// Header values:
6156    ///   line 2: n=2 m=0 num_obj=1 0 0
6157    ///   line 3: 0 1   (1 nonlinear objective)
6158    ///   line 4: 0 0
6159    ///   line 5: 0 2 0 (nonlinear vars in obj=2)
6160    ///   line 6: 0 0 0 1
6161    ///   line 7: 0 0 0 0 0
6162    ///   line 8: 0 0   (no Jacobian nonzeros, no linear obj)
6163    ///   line 9: 0 0
6164    ///   line 10: 0 0 0 0 0
6165    /// Then `O0 0` followed by an expression tree:
6166    /// `(x0 - 1)^2 + (x1 - 2)^2` =
6167    ///   o0
6168    ///     o5 (o1 v0 n1) n2
6169    ///     o5 (o1 v1 n2) n2
6170    /// Then `b` segment: free for both.
6171    const SIMPLE: &str = "g3 0 1 0
61722 0 1 0 0
61730 1
61740 0
61750 2 0
61760 0 0 1
61770 0 0 0 0
61780 0
61790 0
61800 0 0 0 0
6181O0 0
6182o0
6183o5
6184o1
6185v0
6186n1
6187n2
6188o5
6189o1
6190v1
6191n2
6192n2
6193b
61943
61953
6196";
6197
6198    #[test]
6199    fn parses_simple_quadratic() {
6200        let p = parse_nl_text(SIMPLE).expect("parse");
6201        assert_eq!(p.n, 2);
6202        assert_eq!(p.m, 0);
6203        assert_eq!(p.num_obj, 1);
6204        // f(0,0) = 1 + 4 = 5
6205        let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
6206        assert!((f - 5.0).abs() < 1e-12);
6207        // f(1,2) = 0
6208        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
6209        assert!(f.abs() < 1e-12);
6210    }
6211
6212    #[test]
6213    fn gradient_matches_analytic() {
6214        let p = parse_nl_text(SIMPLE).expect("parse");
6215        let x = [0.5, 1.0];
6216        let mut g = [0.0_f64; 2];
6217        grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
6218        // d/dx0 = 2*(x0-1) = -1.0
6219        // d/dx1 = 2*(x1-2) = -2.0
6220        assert!((g[0] - (-1.0)).abs() < 1e-12);
6221        assert!((g[1] - (-2.0)).abs() < 1e-12);
6222    }
6223
6224    /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
6225    /// with global semantics so the presolve auxiliary-elimination
6226    /// safeguard actually engages. Pre-fix the default trait stub returned
6227    /// `false` and left the slice untouched, so a variable that is
6228    /// nonlinear in the objective but zero-gradient at the probe point
6229    /// could be wrongly eliminated.
6230    ///
6231    /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
6232    /// of the objective (NonLinear); x1 appears only in the linear part
6233    /// (Linear).
6234    #[test]
6235    fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
6236        // (x0 - 1)^2
6237        let obj_nl = Expr::Binary(
6238            BinOp::Pow,
6239            Box::new(Expr::Binary(
6240                BinOp::Sub,
6241                Box::new(Expr::Var(0)),
6242                Box::new(Expr::Const(1.0)),
6243            )),
6244            Box::new(Expr::Const(2.0)),
6245        );
6246        let prob = NlProblem {
6247            src: None,
6248            cse_bodies: Vec::new(),
6249            n: 2,
6250            m: 0,
6251            num_obj: 1,
6252            minimize: true,
6253            obj_nonlinear: NlBody::Tree(obj_nl),
6254            obj_linear: vec![(1, 3.0)],
6255            obj_constant: 0.0,
6256            con_nonlinear: vec![],
6257            con_linear: vec![],
6258            x_l: vec![f64::NEG_INFINITY; 2],
6259            x_u: vec![f64::INFINITY; 2],
6260            g_l: vec![],
6261            g_u: vec![],
6262            x0: vec![0.0; 2],
6263            lambda0: vec![],
6264            suffixes: NlSuffixes::default(),
6265            imported_funcs: vec![],
6266            ampl_options: vec![],
6267            nl_counts: None,
6268            var_names: vec![],
6269            con_names: vec![],
6270        };
6271        let mut tnlp = NlTnlp::new(prob);
6272        let mut types = vec![Linearity::Linear; 2];
6273        let ok = tnlp.get_variables_linearity(&mut types);
6274        // Pre-fix: default stub returns false (slice untouched).
6275        assert!(
6276            ok,
6277            "get_variables_linearity must report it filled the slice"
6278        );
6279        assert!(
6280            matches!(types[0], Linearity::NonLinear),
6281            "x0 is nonlinear in the objective"
6282        );
6283        assert!(
6284            matches!(types[1], Linearity::Linear),
6285            "x1 appears only in the linear part"
6286        );
6287    }
6288
6289    /// Objective-scoped linearity must NOT inherit constraint
6290    /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
6291    /// (constraint tape) but linear w.r.t. the objective, so the presolve
6292    /// H11 guard must not treat it as objective-coupled — that was the CI
6293    /// regression where every gas-network variable (nonlinear in the flow
6294    /// equations, absent from the linear objective) blocked Phase-0
6295    /// elimination.
6296    #[test]
6297    fn objective_variables_linearity_ignores_constraint_nonlinearity() {
6298        // x0^2
6299        let con_nl = Expr::Binary(
6300            BinOp::Pow,
6301            Box::new(Expr::Var(0)),
6302            Box::new(Expr::Const(2.0)),
6303        );
6304        let prob = NlProblem {
6305            src: None,
6306            cse_bodies: Vec::new(),
6307            n: 2,
6308            m: 1,
6309            num_obj: 1,
6310            minimize: true,
6311            obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
6312            obj_linear: vec![(1, 3.0)],
6313            obj_constant: 0.0,
6314            con_nonlinear: vec![NlBody::Tree(con_nl)],
6315            con_linear: vec![vec![]],
6316            x_l: vec![f64::NEG_INFINITY; 2],
6317            x_u: vec![f64::INFINITY; 2],
6318            g_l: vec![4.0],
6319            g_u: vec![4.0],
6320            x0: vec![0.0; 2],
6321            lambda0: vec![0.0],
6322            suffixes: NlSuffixes::default(),
6323            imported_funcs: vec![],
6324            ampl_options: vec![],
6325            nl_counts: None,
6326            var_names: vec![],
6327            con_names: vec![],
6328        };
6329        let mut tnlp = NlTnlp::new(prob);
6330
6331        let mut global = vec![Linearity::Linear; 2];
6332        assert!(tnlp.get_variables_linearity(&mut global));
6333        assert!(
6334            matches!(global[0], Linearity::NonLinear),
6335            "global tags see x0's constraint nonlinearity"
6336        );
6337
6338        let mut obj = vec![Linearity::NonLinear; 2];
6339        assert!(tnlp.get_objective_variables_linearity(&mut obj));
6340        assert!(
6341            matches!(obj[0], Linearity::Linear),
6342            "x0 is linear w.r.t. the objective despite the nonlinear constraint"
6343        );
6344        assert!(
6345            matches!(obj[1], Linearity::Linear),
6346            "x1 is linear everywhere"
6347        );
6348    }
6349
6350    /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
6351    /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
6352    /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
6353    /// constraints, and both variables nonlinear in the objective only.
6354    #[test]
6355    fn header_census_is_parsed() {
6356        let p = parse_nl_text(SIMPLE).expect("parse");
6357        let c = p.nl_counts.expect("SIMPLE has a well-formed header");
6358        assert_eq!(c.nl_cons, 0);
6359        assert_eq!(c.nl_objs, 1);
6360        assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
6361        assert_eq!(c.nonlinear_vars(), 2);
6362    }
6363
6364    /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
6365    /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
6366    /// as the overlapping one: disjoint sets add, and `max` would be wrong
6367    /// for them.
6368    #[test]
6369    fn nonlinear_var_total_uses_inclusion_exclusion() {
6370        let c = |vc, vo, vb| NlCounts {
6371            nl_cons: 0,
6372            nl_objs: 0,
6373            nl_vars_cons: vc,
6374            nl_vars_objs: vo,
6375            nl_vars_both: vb,
6376        };
6377        // Fully shared: the same 5 variables in both.
6378        assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
6379        // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
6380        // not the `max(nlvc, nlvo) = 1` a naive reading gives.
6381        assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
6382        // Partial overlap: 4 + 3 - 2.
6383        assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
6384        // Nonsense header: saturates instead of underflowing.
6385        assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
6386    }
6387
6388    /// A header that does not carry the documented fields records no
6389    /// census at all rather than a guess of zero — "no nonlinear
6390    /// variables" is a claim, and a truncated header has not made it.
6391    #[test]
6392    fn short_header_line_records_no_census() {
6393        // Line 5 with two fields instead of `nlvc nlvo nlvb`.
6394        let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
6395        assert_ne!(txt, SIMPLE, "the substitution must have applied");
6396        let p = parse_nl_text(&txt).expect("parse");
6397        assert!(p.nl_counts.is_none());
6398    }
6399
6400    /// `get_number_of_nonlinear_variables` used to be the trait default,
6401    /// `-1` ("assume everything is nonlinear"). It now answers from the
6402    /// trees, and `get_list_of_nonlinear_variables` agrees with it.
6403    ///
6404    /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
6405    #[test]
6406    fn nonlinear_variable_list_excludes_linear_columns() {
6407        let obj_nl = Expr::Binary(
6408            BinOp::Pow,
6409            Box::new(Expr::Binary(
6410                BinOp::Sub,
6411                Box::new(Expr::Var(0)),
6412                Box::new(Expr::Const(1.0)),
6413            )),
6414            Box::new(Expr::Const(2.0)),
6415        );
6416        let parts = NlProblemParts {
6417            minimize: true,
6418            objective: obj_nl,
6419            obj_constant: 0.0,
6420            constraints: vec![],
6421            x_l: vec![-1e19; 2],
6422            x_u: vec![1e19; 2],
6423            x0: vec![0.0; 2],
6424            g_l: vec![],
6425            g_u: vec![],
6426            var_names: vec![],
6427            con_names: vec![],
6428        };
6429        let prob = NlProblem::from_expressions(parts).expect("build");
6430        assert!(
6431            prob.nl_counts.is_none(),
6432            "a model built in memory has no header to read"
6433        );
6434        let mut tnlp = NlTnlp::new(prob);
6435        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
6436        let mut list = [-1 as Index; 2];
6437        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
6438        assert_eq!(list[0], 0);
6439    }
6440
6441    /// When the header says every variable is nonlinear the walk is
6442    /// skipped, and the answer is the same one the walk would give for
6443    /// `SIMPLE` (both variables appear in the objective's nonlinear part).
6444    #[test]
6445    fn all_nonlinear_header_short_circuits_to_n() {
6446        let p = parse_nl_text(SIMPLE).expect("parse");
6447        assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
6448        let mut tnlp = NlTnlp::new(p);
6449        assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
6450        let mut list = [-1 as Index; 2];
6451        assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
6452        assert_eq!(list, [0, 1]);
6453    }
6454
6455    /// The list must not be written when the caller's slice is too small —
6456    /// the contract's `false` return, not a panic.
6457    #[test]
6458    fn nonlinear_variable_list_declines_a_short_slice() {
6459        let p = parse_nl_text(SIMPLE).expect("parse");
6460        let mut tnlp = NlTnlp::new(p);
6461        let mut list = [-1 as Index; 1];
6462        assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
6463        assert_eq!(list, [-1]);
6464    }
6465
6466    /// `min x0^2 + x1^2  s.t.  x0 + x1 = 1`.
6467    /// One equality constraint with a purely linear Jacobian — exercises
6468    /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
6469    /// kind 4).
6470    ///
6471    /// Header layout:
6472    ///   line 1: g3 0 1 0
6473    ///   line 2: 2 1 1 0 0   (n=2, m=1, num_obj=1)
6474    ///   line 3: 0 1         (1 nonlinear obj, 0 nonlinear cons)
6475    ///   line 4: 0 0
6476    ///   line 5: 0 2 0       (nonlinear vars in obj=2)
6477    ///   line 6: 0 0 0 1
6478    ///   line 7: 0 0 0 0 0
6479    ///   line 8: 2 0         (Jacobian nnz=2, no linear obj)
6480    ///   line 9: 0 0
6481    ///   line 10: 0 0 0 0 0
6482    /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
6483    /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
6484    const EQ_LIN: &str = "g3 0 1 0
64852 1 1 0 0
64860 1
64870 0
64880 2 0
64890 0 0 1
64900 0 0 0 0
64912 0
64920 0
64930 0 0 0 0
6494C0
6495n0
6496O0 0
6497o0
6498o5
6499v0
6500n2
6501o5
6502v1
6503n2
6504r
65054 1
6506b
65073
65083
6509k1
65102
6511J0 2
65120 1
65131 1
6514";
6515
6516    #[test]
6517    fn parses_constrained_problem() {
6518        let p = parse_nl_text(EQ_LIN).expect("parse");
6519        assert_eq!(p.n, 2);
6520        assert_eq!(p.m, 1);
6521        // r-segment kind 4 (equality with rhs=1).
6522        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
6523        assert!((p.g_u[0] - 1.0).abs() < 1e-12);
6524        // J-row 0: x0 (coef 1), x1 (coef 1).
6525        assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
6526    }
6527
6528    #[test]
6529    fn malformed_j_variable_index_is_parse_error_not_panic() {
6530        // Code review L32: a J-segment entry's variable (column) index was
6531        // pushed into con_linear unchecked, so an out-of-range index (here 5
6532        // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
6533        // constraint evaluation. It must instead surface as a clean parse
6534        // error, consistent with the existing `J<row> out of range` check.
6535        let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
6536        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
6537        let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
6538        assert!(err.contains("out of range"), "unexpected error: {err}");
6539    }
6540
6541    #[test]
6542    fn out_of_range_x_segment_index_is_parse_error() {
6543        // Same strictness for the initial-primal `x` segment: an index past
6544        // `n` used to be silently dropped; now it is a parse error, so the
6545        // four index-bearing segments (J/G/x/d) behave consistently.
6546        let bad = format!("{EQ_LIN}x1\n5 0.5\n");
6547        let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
6548        assert!(err.contains("out of range"), "unexpected error: {err}");
6549    }
6550
6551    // ---------------------------------------------------------------
6552    // gh #492 — a constant `C<i>` body folds into the row bounds.
6553    //
6554    // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
6555    // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
6556    // constant-body rows to fold.
6557    // ---------------------------------------------------------------
6558
6559    /// Linearity is a *linearity* test, not "did a `C` segment touch this
6560    /// row". A row whose body is the bare constant `3` is affine.
6561    #[test]
6562    fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
6563        // `x0 + x1 + 3 = 1`  ⇔  `x0 + x1 = -2`.
6564        let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
6565        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6566        let p = parse_nl_text(&nl).expect("parse");
6567
6568        assert!(
6569            p.con_nonlinear[0].is_trivially_zero(),
6570            "the constant body must be replaced by the identity zero, got {:?}",
6571            p.con_nonlinear[0]
6572        );
6573        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
6574        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
6575
6576        let mut lin = [Linearity::NonLinear];
6577        let mut t = NlTnlp::new(p);
6578        assert!(t.get_constraints_linearity(&mut lin));
6579        assert_eq!(lin[0], Linearity::Linear);
6580    }
6581
6582    /// The shift must be exact, not merely "linear now": the folded model
6583    /// and the hand-folded one must be the same problem, row body for row
6584    /// body and bound for bound. That is what keeps feasibility, the
6585    /// active set, and the duals unchanged.
6586    #[test]
6587    fn folding_a_row_constant_gives_the_hand_folded_problem() {
6588        // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
6589        let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
6590        let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
6591        assert_ne!(offset, EQ_LIN);
6592        assert_ne!(folded, EQ_LIN);
6593
6594        let a = parse_nl_text(&offset).expect("parse offset form");
6595        let b = parse_nl_text(&folded).expect("parse folded form");
6596        assert_eq!(a.g_l, b.g_l);
6597        assert_eq!(a.g_u, b.g_u);
6598        assert_eq!(a.con_linear, b.con_linear);
6599
6600        // And the row *values* agree pointwise, which is the property the
6601        // duals ride on: `g(x) - g_l` is the same residual either way.
6602        let mut ga = [0.0];
6603        let mut gb = [0.0];
6604        let x = [0.75, -1.25];
6605        assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
6606        assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
6607        assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
6608    }
6609
6610    /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
6611    /// constant as `n3`, and AMPL emits such trees when a expression
6612    /// collapses without being re-simplified.
6613    #[test]
6614    fn a_row_body_that_evaluates_to_a_constant_folds_too() {
6615        // `C0` = `1 + 2`.
6616        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
6617        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6618        let p = parse_nl_text(&nl).expect("parse");
6619        assert!(p.con_nonlinear[0].is_trivially_zero());
6620        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
6621        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
6622    }
6623
6624    /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
6625    /// "absent", not "a very large number". Shifting one turns it into a
6626    /// real bound and invents a constraint that is not in the model.
6627    ///
6628    /// The constants here are deliberately huge. An everyday `3` is
6629    /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
6630    /// presence guard would go unnoticed at ordinary magnitudes and then
6631    /// bite on a model that scales its rows. The guard is what makes the
6632    /// sentinel untouchable at *any* magnitude.
6633    #[test]
6634    fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
6635        // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
6636        // bound. Shifting the lower sentinel would leave `-9e18`, a real
6637        // bound, so the row would gain a floor the model never stated.
6638        let nl = EQ_LIN
6639            .replace("C0\nn0\n", "C0\nn-1e18\n")
6640            .replace("r\n4 1\n", "r\n1 1\n");
6641        let p = parse_nl_text(&nl).expect("parse");
6642        assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
6643        assert!(
6644            !lower_bound_present(p.g_l[0]),
6645            "the absent-lower sentinel became a real bound: {}",
6646            p.g_l[0]
6647        );
6648
6649        // The mirror case: a positive constant on a lower-bounded row is
6650        // the one that would pull the *upper* sentinel below 1e19.
6651        let nl = EQ_LIN
6652            .replace("C0\nn0\n", "C0\nn1e18\n")
6653            .replace("r\n4 1\n", "r\n2 1\n");
6654        let p = parse_nl_text(&nl).expect("parse");
6655        assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
6656        assert!(
6657            !upper_bound_present(p.g_u[0]),
6658            "the absent-upper sentinel became a real bound: {}",
6659            p.g_u[0]
6660        );
6661    }
6662
6663    /// A row body that mentions a variable is not a constant, however
6664    /// simple it looks. Folding it would delete the term.
6665    #[test]
6666    fn a_row_body_with_a_variable_is_not_folded() {
6667        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
6668        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6669        let p = parse_nl_text(&nl).expect("parse");
6670        assert!(
6671            !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
6672            "a row in x0 was folded away: {:?}",
6673            p.con_nonlinear[0]
6674        );
6675        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
6676        assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
6677    }
6678
6679    /// A variable-free body whose value is not finite is left in place. It
6680    /// makes the row infeasible (or ill-posed) and that is the solver's
6681    /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
6682    /// corrupt the bound pair and take every downstream presence test with
6683    /// it.
6684    #[test]
6685    fn a_non_finite_constant_row_body_is_not_folded() {
6686        // `C0` = `log(-1)` = NaN.
6687        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
6688        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6689        let p = parse_nl_text(&nl).expect("parse");
6690        assert!(
6691            !p.con_nonlinear[0].is_trivially_zero(),
6692            "a NaN body was folded into the bounds"
6693        );
6694        assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
6695        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
6696    }
6697
6698    /// An imported-function call is not a parse-time constant even with
6699    /// constant arguments — it is resolved to a shared library much later
6700    /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
6701    /// rather than guess. The fold must decline before it evaluates.
6702    #[test]
6703    fn a_constant_argument_funcall_row_body_is_not_folded() {
6704        // Declare one imported function and call it with a literal.
6705        let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
6706        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6707        let p = parse_nl_text(&nl).expect("parse");
6708        assert!(
6709            matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
6710            "expected the funcall to survive the fold, got {:?}",
6711            p.con_nonlinear[0]
6712        );
6713        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
6714    }
6715
6716    #[test]
6717    fn k_segment_nonstandard_count_is_parse_error_at_source() {
6718        // Code review L35: the `k` (Jacobian column-count) segment header
6719        // declares how many count lines follow — `k<count>` — and the
6720        // standard value is n-1. The parser used to *assume* n-1 and ignore
6721        // the header, so a file declaring a different count read the wrong
6722        // number of data lines, desynced the segment stream, and failed far
6723        // downstream with a confusing error (or silently mis-parsed). With
6724        // the declared count now read and validated, a nonstandard count is
6725        // a clear parse error at its source. Here EQ_LIN has n=2 (expected
6726        // count 1); rewrite its `k1` + one count line to `k0`.
6727        let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
6728        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
6729        let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
6730        assert!(
6731            err.contains("k-segment declares"),
6732            "expected a clear k-segment count error, got: {err}"
6733        );
6734    }
6735
6736    #[test]
6737    fn get_starting_point_returns_nl_initial_duals() {
6738        // Code review 2026-06 item M19: the `.nl` `d` segment supplies
6739        // initial constraint multipliers. They are parsed into `lambda0`,
6740        // but `get_starting_point` previously ignored them — so a
6741        // `warm_start_init_point yes` solve silently began from zero duals.
6742        // `get_starting_point` must hand the parsed duals back when the
6743        // engine requests them (`init_lambda`), and leave the buffer
6744        // untouched when it does not.
6745        let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
6746        let p = parse_nl_text(&nl).expect("parse");
6747        assert_eq!(p.lambda0, vec![2.5], "the `d` segment fills lambda0");
6748
6749        let mut t = NlTnlp::new(p);
6750        let info = t.get_nlp_info().unwrap();
6751        let (n, m) = (info.n as usize, info.m as usize);
6752
6753        // Warm-start request: init_lambda = true → the parsed `.nl` duals
6754        // must be returned (pre-fix this stayed zero).
6755        let mut x = vec![0.0; n];
6756        let mut z_l = vec![0.0; n];
6757        let mut z_u = vec![0.0; n];
6758        let mut lambda = vec![0.0; m];
6759        assert!(t.get_starting_point(StartingPoint {
6760            init_x: true,
6761            x: &mut x,
6762            init_z: false,
6763            z_l: &mut z_l,
6764            z_u: &mut z_u,
6765            init_lambda: true,
6766            lambda: &mut lambda,
6767        }));
6768        assert_eq!(
6769            lambda,
6770            vec![2.5],
6771            "a warm start must use the `.nl` initial duals, not zero"
6772        );
6773
6774        // No warm-start request: the multiplier buffer is left alone (the
6775        // engine owns its default), so honoring the flag does not clobber it.
6776        let mut lambda_untouched = vec![7.0; m];
6777        assert!(t.get_starting_point(StartingPoint {
6778            init_x: true,
6779            x: &mut x,
6780            init_z: false,
6781            z_l: &mut z_l,
6782            z_u: &mut z_u,
6783            init_lambda: false,
6784            lambda: &mut lambda_untouched,
6785        }));
6786        assert_eq!(
6787            lambda_untouched,
6788            vec![7.0],
6789            "without init_lambda the multiplier buffer must be untouched"
6790        );
6791    }
6792
6793    /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
6794    /// unconstrained, `n` variables. The trailing product puts a nonzero
6795    /// in row 0 of *every* Hessian column, which is the shape that used
6796    /// to force the greedy coloring to hand out one color per variable.
6797    fn dense_row_objective_nl(n: usize) -> String {
6798        let mut s = String::new();
6799        s.push_str("g3 1 1 0\n");
6800        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
6801        s.push_str(" 0 1\n 0 0\n");
6802        s.push_str(&format!(" {n} {n} {n}\n"));
6803        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
6804        s.push_str(&format!(" 0 {n}\n"));
6805        s.push_str(" 0 0\n 0 0 0 0 0\n");
6806        // objective
6807        s.push_str("O0 0\n");
6808        s.push_str(&format!("o54\n{}\n", n + 1));
6809        for j in 0..n {
6810            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
6811        }
6812        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
6813        for j in 0..n {
6814            s.push_str(&format!("v{j}\n"));
6815        }
6816        // start, bounds, gradient
6817        s.push_str(&format!("x{n}\n"));
6818        for j in 0..n {
6819            s.push_str(&format!("{j} 0.5\n"));
6820        }
6821        s.push_str("b\n");
6822        for _ in 0..n {
6823            s.push_str("3\n");
6824        }
6825        s.push_str(&format!("G0 {n}\n"));
6826        for j in 0..n {
6827            s.push_str(&format!("{j} 0.0\n"));
6828        }
6829        s
6830    }
6831
6832    /// Same shape as [`dense_row_objective_nl`], but the coupling term
6833    /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
6834    /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
6835    /// magnitude makes the dense column ill-scaled — the case where
6836    /// reading its entries out of its own pass costs real digits.
6837    fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
6838        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
6839        let mut s = String::new();
6840        s.push_str("g3 1 1 0\n");
6841        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
6842        s.push_str(" 0 1\n 0 0\n");
6843        s.push_str(&format!(" {n} {n} {n}\n"));
6844        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
6845        s.push_str(&format!(" 0 {n}\n"));
6846        s.push_str(" 0 0\n 0 0 0 0 0\n");
6847        s.push_str("O0 0\n");
6848        s.push_str(&format!("o54\n{}\n", n + 1));
6849        for j in 0..n {
6850            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
6851        }
6852        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
6853        for j in 0..n {
6854            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
6855        }
6856        s.push_str(&format!("x{n}\n"));
6857        for j in 0..n {
6858            s.push_str(&format!("{j} 0.5\n"));
6859        }
6860        s.push_str("b\n");
6861        for _ in 0..n {
6862            s.push_str("3\n");
6863        }
6864        s.push_str(&format!("G0 {n}\n"));
6865        for j in 0..n {
6866            s.push_str(&format!("{j} 0.0\n"));
6867        }
6868        s
6869    }
6870
6871    /// Locate a model in the benchmark corpus, or `None` if the corpus is
6872    /// not on this machine.
6873    ///
6874    /// The corpus is ~2 GB and deliberately outside the checkout (see
6875    /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
6876    /// no-op rather than fail. That is a real limitation — a check that
6877    /// silently does nothing is how the gap below went unnoticed in the
6878    /// first place — so anything using this must also be covered by a
6879    /// synthetic case that always runs.
6880    fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
6881        let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
6882        let p = std::path::PathBuf::from(root).join(rel);
6883        p.is_file().then_some(p)
6884    }
6885
6886    /// The corpus check the dense-column optimization never got.
6887    ///
6888    /// `cho_parmest` is the model whose certificate the optimization cost,
6889    /// and it could not have caught it: the model is 4.3 MB and lives in
6890    /// the benchmark data set, not in the repository, so validating
6891    /// against the in-repo `.nl` fixtures said nothing about it. This test
6892    /// closes that by checking the corpus directly wherever the corpus
6893    /// exists, which is every machine and CI job that runs the benchmarks.
6894    ///
6895    /// The assertion is the one that matters and the one that was never
6896    /// made: whatever the guard leaves peeled must decode to the same
6897    /// Hessian as peeling nothing. Against the pre-fix code this fails —
6898    /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
6899    #[test]
6900    fn cho_parmest_decodes_to_its_unpeeled_reference() {
6901        let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
6902            eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
6903            return;
6904        };
6905        let p = read_nl_file(&path).expect("read cho_parmest");
6906        let n = p.n;
6907        let mut t = NlTnlp::new(p);
6908        // The guard vetoes 7 of cho's 12 peeled columns, and putting those
6909        // seven dense rows back into the conflict graph costs the coloring
6910        // outright: it goes from 17 colors to 9010, and no column clears the
6911        // density threshold afterwards, so the model ends up fully unpeeled.
6912        // That is the price of the certificate on this model, and it is worth
6913        // knowing rather than assuming the other five survive.
6914        assert!(t.peeled_cols.is_empty());
6915
6916        let info = t.get_nlp_info().unwrap();
6917        let nnz = info.nnz_h_lag as usize;
6918        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
6919        assert!(t.eval_h(
6920            None,
6921            true,
6922            1.0,
6923            None,
6924            true,
6925            SparsityRequest::Structure {
6926                irow: &mut irow,
6927                jcol: &mut jcol
6928            }
6929        ));
6930        // Evaluate away from `x0` with non-uniform multipliers. The damage
6931        // is point-dependent, and `x0` is close to where it is least
6932        // visible: only 5 entries move there, against 48,931 here. Note the
6933        // guard's own probe runs at `x0` — it fires anyway because it tests
6934        // a bound on what a pass *could* lose, not the damage it happens to
6935        // commit at one point. That is the property that makes it robust,
6936        // and this asymmetry is worth keeping in front of anyone who
6937        // retunes it.
6938        let x: Vec<f64> = t
6939            .prob
6940            .x0
6941            .iter()
6942            .enumerate()
6943            .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
6944            .collect();
6945        let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
6946
6947        let mut got = vec![0.0_f64; nnz];
6948        assert!(t.eval_h(
6949            Some(&x),
6950            true,
6951            1.0,
6952            Some(&lambda),
6953            true,
6954            SparsityRequest::Values { values: &mut got }
6955        ));
6956
6957        t.recolor(&vec![true; n]);
6958        assert!(t.peeled_cols.is_empty());
6959        let mut want = vec![0.0_f64; nnz];
6960        assert!(t.eval_h(
6961            Some(&x),
6962            true,
6963            1.0,
6964            Some(&lambda),
6965            true,
6966            SparsityRequest::Values { values: &mut want }
6967        ));
6968
6969        let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
6970        let mut worst = 0.0_f64;
6971        let mut at = 0usize;
6972        for k in 0..nnz {
6973            let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
6974            if rel > worst {
6975                worst = rel;
6976                at = k;
6977            }
6978        }
6979        assert!(
6980            worst <= 1e-13,
6981            "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
6982             {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
6983             out of a pass it cannot be read out of.",
6984            irow[at],
6985            jcol[at],
6986            got[at],
6987            want[at]
6988        );
6989
6990        // The check above passes trivially on correct code, because the
6991        // guard leaves cho fully unpeeled and so compares a configuration
6992        // against itself. It only has teeth against a regression. So prove
6993        // the guard is doing necessary work rather than assuming it: put
6994        // the peels back the way the pre-fix reader had them and confirm
6995        // the Hessian really does come apart.
6996        t.recolor(&vec![false; n]);
6997        assert!(
6998            !t.peeled_cols.is_empty(),
6999            "restoring the unvetoed coloring must peel again"
7000        );
7001        let mut unguarded = vec![0.0_f64; nnz];
7002        assert!(t.eval_h(
7003            Some(&x),
7004            true,
7005            1.0,
7006            Some(&lambda),
7007            true,
7008            SparsityRequest::Values {
7009                values: &mut unguarded
7010            }
7011        ));
7012        let mut bad = 0usize;
7013        let mut worst_unguarded = 0.0_f64;
7014        for k in 0..nnz {
7015            let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
7016            if rel > 1e-13 {
7017                bad += 1;
7018            }
7019            worst_unguarded = worst_unguarded.max(rel);
7020        }
7021        // Two different counts get quoted about this model and they measure
7022        // different things: 48,931 of the 96,000 entries differ from the
7023        // uncompressed reference *at all*, down to the last bit, while 88
7024        // exceed 1e-13 relative. The second is the one worth asserting on.
7025        assert!(
7026            bad >= 50 && worst_unguarded > 1e-11,
7027            "peeling cho_parmest unguarded should damage the entries the guard \
7028             exists to protect (measured: 88 entries past 1e-13 relative, \
7029             worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
7030             this fires, the model or the corpus changed and the guard's \
7031             calibration should be re-derived rather than the bound relaxed."
7032        );
7033        eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
7034    }
7035
7036    /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
7037    /// the dense variable is `x_{n-1}` instead of `x_0`:
7038    /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
7039    ///
7040    /// The index matters, and it is the whole reason this helper exists.
7041    /// With the dense variable at 0 every coupling entry is stored as
7042    /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
7043    /// out of column 0's own pass whether or not peeling is on, so the two
7044    /// paths are bit-identical and no test built on that shape can tell
7045    /// them apart. Putting the dense variable last stores them as
7046    /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
7047    /// back from column `n-1`'s pass by symmetry, carrying that pass's
7048    /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
7049    /// That is the category every one of `cho_parmest`'s 48,931 damaged
7050    /// entries fell into.
7051    fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
7052        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
7053        let d = n - 1;
7054        let mut s = String::new();
7055        s.push_str("g3 1 1 0\n");
7056        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
7057        s.push_str(" 0 1\n 0 0\n");
7058        s.push_str(&format!(" {n} {n} {n}\n"));
7059        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
7060        s.push_str(&format!(" 0 {n}\n"));
7061        s.push_str(" 0 0\n 0 0 0 0 0\n");
7062        s.push_str("O0 0\n");
7063        s.push_str(&format!("o54\n{}\n", n + 1));
7064        for j in 0..n {
7065            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
7066        }
7067        s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
7068        for j in 0..d {
7069            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
7070        }
7071        s.push_str(&format!("x{n}\n"));
7072        for j in 0..n {
7073            s.push_str(&format!("{j} 0.5\n"));
7074        }
7075        s.push_str("b\n");
7076        for _ in 0..n {
7077            s.push_str("3\n");
7078        }
7079        s.push_str(&format!("G0 {n}\n"));
7080        for j in 0..n {
7081            s.push_str(&format!("{j} 0.0\n"));
7082        }
7083        s
7084    }
7085
7086    /// A well-scaled dense column is still peeled, and the entries read
7087    /// out of its pass are exact — the property the peel guard must not
7088    /// cost us.
7089    #[test]
7090    fn a_well_scaled_dense_column_is_still_peeled() {
7091        let n = 200;
7092        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
7093        let t = NlTnlp::new(p);
7094        assert_eq!(
7095            t.peeled_cols,
7096            vec![0],
7097            "a dense column that costs no accuracy must stay peeled"
7098        );
7099        assert!(
7100            t.seeds.len() <= 4,
7101            "peeling should keep the color count at O(1), got {}",
7102            t.seeds.len()
7103        );
7104    }
7105
7106    /// An ill-scaled dense column must be un-peeled and colored the
7107    /// ordinary way, so its small entries come back to full precision.
7108    ///
7109    /// This test covers the *guard*, not the damage. It asserts that a
7110    /// column spanning 12 orders is un-peeled and that its entries are then
7111    /// exact — and it is worth being precise that it does not, and cannot,
7112    /// show what peeling would have cost, because on this model peeling
7113    /// costs nothing: force the peel through and every entry still comes
7114    /// back bit-identical to its analytic weight. The Hessian here is
7115    /// constant and each entry is a single product, so there is no
7116    /// accumulation for a large-magnitude pass to pollute.
7117    ///
7118    /// Reproducing the actual digit loss takes a model whose entries are
7119    /// summed through shared intermediates, which is why the demonstration
7120    /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
7121    /// real model rather than a synthetic one.
7122    #[test]
7123    fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
7124        let n = 200;
7125        let span = 12.0;
7126        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
7127        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
7128        let mut t = NlTnlp::new(p);
7129
7130        assert!(
7131            t.peeled_cols.is_empty(),
7132            "a dense column spanning {span} orders must not be peeled; got {:?}",
7133            t.peeled_cols
7134        );
7135
7136        let info = t.get_nlp_info().unwrap();
7137        let nnz = info.nnz_h_lag as usize;
7138        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
7139        assert!(t.eval_h(
7140            None,
7141            true,
7142            1.0,
7143            None,
7144            true,
7145            SparsityRequest::Structure {
7146                irow: &mut irow,
7147                jcol: &mut jcol
7148            }
7149        ));
7150        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
7151        let mut vals = vec![0.0_f64; nnz];
7152        assert!(t.eval_h(
7153            Some(&x),
7154            true,
7155            1.0,
7156            None,
7157            true,
7158            SparsityRequest::Values { values: &mut vals }
7159        ));
7160
7161        // Every coupling entry must be its weight to full relative
7162        // precision. Peeled, the smallest of them came back with a
7163        // relative error near 1e-4.
7164        let mut checked = 0;
7165        for k in 0..nnz {
7166            let (i, j) = (irow[k] as usize, jcol[k] as usize);
7167            if i == j {
7168                continue;
7169            }
7170            assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
7171            checked += 1;
7172            let want = w(i);
7173            assert!(
7174                (vals[k] - want).abs() <= 1e-13 * want,
7175                "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
7176                vals[k],
7177                (vals[k] - want).abs() / want
7178            );
7179        }
7180        assert_eq!(checked, n - 1);
7181    }
7182
7183    /// Whatever survives the peel guard must decode to the same Hessian
7184    /// that not peeling at all produces — every entry, not just the
7185    /// objective.
7186    ///
7187    /// This exists because the original dense-column optimization was
7188    /// validated by running the repository's `.nl` fixtures and comparing
7189    /// objective value and exit status, which was doubly blind: not one of
7190    /// the 60 fixtures has a Hessian row dense enough to peel anything, so
7191    /// the decode path under test never executed, and even had it executed,
7192    /// the defect cost digits in the multipliers while leaving the
7193    /// objective intact. So the instrument has to be the assembled Hessian
7194    /// and the input has to actually peel — hence `peeled_any` below, which
7195    /// fails if the sweep ever goes vacuous the way the fixture suite
7196    /// silently did.
7197    ///
7198    /// What this catches is a *decode* fault: an entry recovered from the
7199    /// wrong pass, which is wrong by O(1). It would not have caught the
7200    /// `cho_parmest` stall, because these synthetic models lose no
7201    /// precision under peeling at all (see
7202    /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
7203    /// precision half is covered against the real model in
7204    /// `cho_parmest_decodes_to_its_unpeeled_reference`.
7205    #[test]
7206    fn a_peeled_decode_matches_an_unpeeled_reference() {
7207        // `last = true` puts the dense variable at `n-1`, so the coupling
7208        // entries are stored with the peeled column as their *row* — the
7209        // only shape in which peeling and not peeling read an entry out of
7210        // different passes, and so the only shape that can detect a
7211        // difference at all. See `weighted_dense_last_col_nl`.
7212        let cases = [
7213            (200, 0.0, false),
7214            (200, 2.0, false),
7215            (200, 0.0, true),
7216            (200, 1.0, true),
7217            (200, 2.0, true),
7218            (400, 3.0, true),
7219            (600, 0.0, true),
7220        ];
7221        let mut peeled_any = false;
7222        let mut worst = 0.0_f64;
7223
7224        for &(n, span, last) in &cases {
7225            let text = if last {
7226                weighted_dense_last_col_nl(n, span)
7227            } else {
7228                weighted_dense_row_objective_nl(n, span)
7229            };
7230            let p = parse_nl_text(&text).expect("parse");
7231            let mut t = NlTnlp::new(p);
7232            let peeled = t.peeled_cols.clone();
7233            peeled_any |= !peeled.is_empty();
7234
7235            let info = t.get_nlp_info().unwrap();
7236            let nnz = info.nnz_h_lag as usize;
7237            let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
7238            assert!(t.eval_h(
7239                None,
7240                true,
7241                1.0,
7242                None,
7243                true,
7244                SparsityRequest::Structure {
7245                    irow: &mut irow,
7246                    jcol: &mut jcol
7247                }
7248            ));
7249            let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
7250
7251            let mut got = vec![0.0_f64; nnz];
7252            assert!(t.eval_h(
7253                Some(&x),
7254                true,
7255                1.0,
7256                None,
7257                true,
7258                SparsityRequest::Values { values: &mut got }
7259            ));
7260
7261            // Same object, same tapes, same point — only the coloring
7262            // differs, so any disagreement is the decode path.
7263            t.recolor(&vec![true; n]);
7264            assert!(
7265                t.peeled_cols.is_empty(),
7266                "a fully vetoed model must peel nothing"
7267            );
7268            let mut want = vec![0.0_f64; nnz];
7269            assert!(t.eval_h(
7270                Some(&x),
7271                true,
7272                1.0,
7273                None,
7274                true,
7275                SparsityRequest::Values { values: &mut want }
7276            ));
7277
7278            for k in 0..nnz {
7279                let scale = want[k].abs().max(f64::MIN_POSITIVE);
7280                let rel = (got[k] - want[k]).abs() / scale;
7281                worst = worst.max(rel);
7282                assert!(
7283                    rel <= 1e-13,
7284                    "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
7285                     unpeeled reference {:e} (relative error {rel:e})",
7286                    irow[k],
7287                    jcol[k],
7288                    got[k],
7289                    want[k]
7290                );
7291            }
7292        }
7293
7294        assert!(
7295            peeled_any,
7296            "no case peeled anything, so this test proved nothing about the \
7297             decode path — the exact way the fixture suite missed the bug"
7298        );
7299        assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
7300    }
7301
7302    /// `peel_veto` bars a column from the peel set, and the row it puts
7303    /// back into the conflict structure costs the colors it used to save.
7304    #[test]
7305    fn a_vetoed_column_is_colored_the_ordinary_way() {
7306        let n = 300;
7307        // One dense row plus a diagonal: column 0 touches every row.
7308        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7309        pairs.extend((1..n).map(|i| (i, 0)));
7310        pairs.sort_unstable();
7311
7312        let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7313        assert!(peeled[0], "the dense column should peel by default");
7314        assert!(
7315            colors_peeled <= 4,
7316            "peeling should collapse the count, got {colors_peeled}"
7317        );
7318
7319        let mut veto = vec![false; n];
7320        veto[0] = true;
7321        let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
7322        assert!(!peeled[0], "a vetoed column must not be peeled");
7323        assert!(
7324            colors_vetoed > colors_peeled,
7325            "un-peeling restores row 0's conflicts, so colors must rise: \
7326             {colors_vetoed} vs {colors_peeled}"
7327        );
7328    }
7329
7330    /// A single dense Hessian row must not blow the coloring up to one
7331    /// color per variable. It used to: every column shares row 0, so no
7332    /// two columns could be colored alike, and `seeds` / `compressed`
7333    /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
7334    /// per `eval_h` on a Hessian holding only ~2n nonzeros.
7335    #[test]
7336    fn a_dense_hessian_row_does_not_explode_the_coloring() {
7337        let n = 200;
7338        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
7339        let t = NlTnlp::new(p);
7340        // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
7341        // for j >= 1 from the coupling term.
7342        assert_eq!(t.h_irow.len(), 2 * n - 1);
7343        assert!(
7344            t.seeds.len() <= 4,
7345            "one dense row should cost one extra color, not n; got {} colors for n={n}",
7346            t.seeds.len()
7347        );
7348        assert_eq!(t.seeds.len(), t.compressed.len());
7349    }
7350
7351    /// Peeling changes *which* directional product recovers an entry, so
7352    /// the recovered Hessian must still be exactly right — including the
7353    /// entries read out of a peeled column's pass by symmetry.
7354    #[test]
7355    fn peeled_dense_column_still_recovers_the_exact_hessian() {
7356        let n = 200;
7357        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
7358        let mut t = NlTnlp::new(p);
7359        let info = t.get_nlp_info().unwrap();
7360        let nnz = info.nnz_h_lag as usize;
7361
7362        let mut irow = vec![0_i32; nnz];
7363        let mut jcol = vec![0_i32; nnz];
7364        assert!(t.eval_h(
7365            None,
7366            true,
7367            1.0,
7368            None,
7369            true,
7370            SparsityRequest::Structure {
7371                irow: &mut irow,
7372                jcol: &mut jcol
7373            }
7374        ));
7375
7376        // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
7377        //   H[0,0] = 2 + 2 = 4;  H[j,j] = 2 (j >= 1);  H[j,0] = 1 (j >= 1).
7378        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
7379        let obj_factor = 2.5;
7380        let mut vals = vec![0.0_f64; nnz];
7381        assert!(t.eval_h(
7382            Some(&x),
7383            true,
7384            obj_factor,
7385            None,
7386            true,
7387            SparsityRequest::Values { values: &mut vals }
7388        ));
7389
7390        let mut seen_diag = 0;
7391        let mut seen_coupling = 0;
7392        for k in 0..nnz {
7393            let (i, j) = (irow[k] as usize, jcol[k] as usize);
7394            let want = if i == 0 && j == 0 {
7395                4.0
7396            } else if i == j {
7397                seen_diag += 1;
7398                2.0
7399            } else {
7400                assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
7401                seen_coupling += 1;
7402                1.0
7403            } * obj_factor;
7404            assert!(
7405                (vals[k] - want).abs() < 1e-12,
7406                "H[{i},{j}] = {}, want {want}",
7407                vals[k]
7408            );
7409        }
7410        assert_eq!(seen_diag, n - 1);
7411        assert_eq!(seen_coupling, n - 1);
7412    }
7413
7414    /// The peeling threshold must leave ordinary sparse models on
7415    /// exactly the coloring they had before: a banded Hessian colors by
7416    /// its bandwidth, with nothing peeled.
7417    #[test]
7418    fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
7419        let n = 400;
7420        let pairs: Vec<(usize, usize)> = (0..n)
7421            .flat_map(|j| {
7422                let mut v = vec![(j, j)];
7423                if j + 1 < n {
7424                    v.push((j + 1, j));
7425                }
7426                v
7427            })
7428            .collect();
7429        let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7430        assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
7431        assert!(
7432            n_colors <= 3,
7433            "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
7434        );
7435        assert!(var_color.iter().all(|&c| c != u32::MAX));
7436    }
7437
7438    /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
7439    /// scattered through an otherwise diagonal `n`-variable problem.
7440    /// A plain coloring needs exactly `size` colors no matter how many
7441    /// blocks there are, because the blocks share no rows.
7442    fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
7443        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7444        let stride = n / blocks;
7445        for b in 0..blocks {
7446            let base = b * stride;
7447            for i in 0..size {
7448                for j in 0..=i {
7449                    if i != j {
7450                        pairs.push((base + i, base + j));
7451                    }
7452                }
7453            }
7454        }
7455        pairs
7456    }
7457
7458    /// Many medium-degree columns must not be peeled.
7459    ///
7460    /// Regression: selecting candidates by "degree > 16x average" and then
7461    /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
7462    /// The columns that miss the cut stay in the conflict structure, so the
7463    /// base color count is untouched and the singleton colors are pure
7464    /// addition — these two patterns colored to 306 and 290 against a plain
7465    /// walk's 50 and 34.
7466    #[test]
7467    fn thousands_of_medium_degree_cols_are_not_peeled() {
7468        for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
7469            let pairs = disjoint_blocks(n, blocks, size);
7470            let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7471            let n_peeled = peeled.iter().filter(|&&p| p).count();
7472            assert_eq!(
7473                n_peeled, 0,
7474                "degree-{size} columns do not pay for a singleton color \
7475                 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
7476            );
7477            assert!(
7478                n_colors <= size + 1,
7479                "disjoint {size}x{size} blocks color by block size regardless \
7480                 of block count; got {n_colors} for n={n}, blocks={blocks}"
7481            );
7482        }
7483    }
7484
7485    /// The pay-for-itself rule must not throw away the case peeling exists
7486    /// for: a handful of genuinely dense rows still get peeled, and still
7487    /// collapse the coloring.
7488    #[test]
7489    fn a_few_truly_dense_rows_are_still_peeled() {
7490        let n = 5_000;
7491        let dense_rows = 4;
7492        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7493        for d in 0..dense_rows {
7494            for j in 0..n {
7495                if j != d {
7496                    pairs.push((j.max(d), j.min(d)));
7497                }
7498            }
7499        }
7500        let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7501        let n_peeled = peeled.iter().filter(|&&p| p).count();
7502        assert_eq!(n_peeled, dense_rows, "every full row should peel");
7503        assert!(
7504            n_colors <= dense_rows + 2,
7505            "peeling {dense_rows} full rows should leave a diagonal remainder, \
7506             got {n_colors} colors"
7507        );
7508    }
7509
7510    /// The cap still binds, and when it does the kept columns are the
7511    /// highest-degree ones.
7512    #[test]
7513    fn peeling_is_capped_and_keeps_the_worst_offenders() {
7514        let n = 20_000;
7515        let dense_rows = 400;
7516        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7517        for d in 0..dense_rows {
7518            // Row d touches the first (n - d) columns, so degree strictly
7519            // decreases with d and the ordering is unambiguous.
7520            for j in 0..(n - d) {
7521                if j != d {
7522                    pairs.push((j.max(d), j.min(d)));
7523                }
7524            }
7525        }
7526        let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7527        let n_peeled = peeled.iter().filter(|&&p| p).count();
7528        assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
7529        assert!(
7530            (0..MAX_PEELED_COLS).all(|d| peeled[d]),
7531            "the {MAX_PEELED_COLS} densest rows are the ones kept"
7532        );
7533    }
7534
7535    /// Header line 0 is `g<count> <opt0> ...`; the option words are the
7536    /// model's own and a solver echoes them into the `.sol` `Options`
7537    /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
7538    #[test]
7539    fn header_option_words_are_kept_verbatim() {
7540        let p = parse_nl_text(EQ_LIN).expect("parse");
7541        assert_eq!(p.ampl_options, vec![0, 1, 0]);
7542    }
7543
7544    /// A count that does not match the words present must not be
7545    /// guessed at — the writer falls back rather than emit a wrong block.
7546    #[test]
7547    fn a_truncated_option_list_is_dropped_not_padded() {
7548        let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
7549        let p = parse_nl_text(&text).expect("parse");
7550        assert!(
7551            p.ampl_options.is_empty(),
7552            "9 declared but 3 present: {:?}",
7553            p.ampl_options
7554        );
7555    }
7556
7557    #[test]
7558    fn constrained_tnlp_eval_g_jac_h() {
7559        let p = parse_nl_text(EQ_LIN).expect("parse");
7560        let mut t = NlTnlp::new(p);
7561        let info = t.get_nlp_info().unwrap();
7562        assert_eq!(info.m, 1);
7563        assert_eq!(info.nnz_jac_g, 2);
7564
7565        // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
7566        let mut g = [0.0_f64; 1];
7567        assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
7568        assert!((g[0] - 0.7).abs() < 1e-12);
7569
7570        // Jacobian structure: row 0, cols [0, 1].
7571        let mut irow = [0_i32; 2];
7572        let mut jcol = [0_i32; 2];
7573        assert!(t.eval_jac_g(
7574            None,
7575            true,
7576            SparsityRequest::Structure {
7577                irow: &mut irow,
7578                jcol: &mut jcol
7579            }
7580        ));
7581        assert_eq!(irow, [0, 0]);
7582        assert_eq!(jcol, [0, 1]);
7583
7584        // Jacobian values: both 1.0.
7585        let mut vals = [0.0_f64; 2];
7586        assert!(t.eval_jac_g(
7587            Some(&[0.3, 0.4]),
7588            true,
7589            SparsityRequest::Values { values: &mut vals }
7590        ));
7591        assert!((vals[0] - 1.0).abs() < 1e-12);
7592        assert!((vals[1] - 1.0).abs() < 1e-12);
7593
7594        // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
7595        // λ contributes nothing because the constraint is linear, and
7596        // x0^2 + x1^2 is separable so there's no (1,0) entry in the
7597        // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
7598        assert_eq!(info.nnz_h_lag, 2);
7599        let mut hirow = [0_i32; 2];
7600        let mut hjcol = [0_i32; 2];
7601        assert!(t.eval_h(
7602            None,
7603            true,
7604            1.0,
7605            None,
7606            true,
7607            SparsityRequest::Structure {
7608                irow: &mut hirow,
7609                jcol: &mut hjcol
7610            }
7611        ));
7612        assert_eq!(hirow, [0, 1]);
7613        assert_eq!(hjcol, [0, 1]);
7614        let mut hvals = [0.0_f64; 2];
7615        assert!(t.eval_h(
7616            Some(&[0.3, 0.4]),
7617            true,
7618            1.0,
7619            Some(&[0.5]),
7620            true,
7621            SparsityRequest::Values { values: &mut hvals }
7622        ));
7623        assert!((hvals[0] - 2.0).abs() < 1e-12);
7624        assert!((hvals[1] - 2.0).abs() < 1e-12);
7625    }
7626
7627    /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
7628    /// encoded as common-subexpression `V2`. Header line 10 declares
7629    /// one obj-only CSE; expression tree references `v2` twice.
7630    const CSE_OBJ: &str = "g3 0 1 0
76312 0 1 0 0
76320 1
76330 0
76340 2 0
76350 0 0 1
76360 0 0 0 0
76370 0
76380 0
76390 1 0 0 0
7640V2 0 0
7641o0
7642v0
7643v1
7644O0 0
7645o0
7646o5
7647v2
7648n2
7649v2
7650b
76513
76523
7653";
7654
7655    #[test]
7656    fn parses_v_segment_cse() {
7657        let p = parse_nl_text(CSE_OBJ).expect("parse");
7658        assert_eq!(p.n, 2);
7659        // f(1,2) = 9 + 3 = 12
7660        let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7661        assert!((f - 12.0).abs() < 1e-12, "got {f}");
7662        // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
7663        let mut g = [0.0_f64; 2];
7664        grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
7665        assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
7666        assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
7667        // collect_vars reaches into the CSE body and finds {0, 1}.
7668        let mut vs = BTreeSet::new();
7669        p.obj_nonlinear.collect_vars(&mut vs);
7670        assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
7671    }
7672
7673    /// `min (x0 - 1)^2` with three suffix segments attached: an
7674    /// integer constraint-suffix (target=1, kind=1), an integer var-
7675    /// suffix (target=0, kind=0), and a real var-suffix (target=0,
7676    /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
7677    /// `<idx> <value>` lines.
7678    const WITH_SUFFIXES: &str = "g3 0 1 0
76791 0 1 0 0
76800 1
76810 0
76820 1 0
76830 0 0 1
76840 0 0 0 0
76850 0
76860 0
76870 0 0 0 0
7688O0 0
7689o5
7690o1
7691v0
7692n1
7693n2
7694b
76953
7696S0 1 sens_state_1
76970 7
7698S4 1 sens_state_value_1
76990 4.5
7700";
7701
7702    #[test]
7703    fn parses_var_int_and_var_real_suffixes() {
7704        let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
7705        // Integer var-suffix: dense length 1, slot 0 = 7.
7706        let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
7707        assert_eq!(v.as_slice(), &[7]);
7708        // Real var-suffix: dense length 1, slot 0 = 4.5.
7709        let r = p
7710            .suffixes
7711            .var_real
7712            .get("sens_state_value_1")
7713            .expect("var_real");
7714        assert_eq!(r.len(), 1);
7715        assert!((r[0] - 4.5).abs() < 1e-12);
7716        // Other suffix slots stay empty.
7717        assert!(p.suffixes.con_int.is_empty());
7718        assert!(p.suffixes.con_real.is_empty());
7719    }
7720
7721    /// Two-variable + two-constraint problem with a constraint-level
7722    /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
7723    const WITH_CON_SUFFIX: &str = "g3 0 1 0
77242 2 1 0 0
77250 0
77260 0
77270 2 0
77280 0 0 1
77290 0 0 0 0
77304 0
77310 0
77320 0 0 0 0 0
7733C0
7734n0
7735C1
7736n0
7737O0 0
7738n0
7739r
77404 0.0
77414 0.0
7742b
77433
77443
7745k1
77460
7747J0 2
77480 1
77491 1
7750J1 2
77510 1
77521 -1
7753S1 2 sens_init_constr
77540 1
77551 2
7756";
7757
7758    #[test]
7759    fn parses_con_int_suffix() {
7760        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
7761        let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
7762        // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
7763        assert_eq!(s.as_slice(), &[1, 2]);
7764    }
7765
7766    // ---- gh#785: a truncated file is rejected, not silently defaulted ----
7767    //
7768    // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
7769    // segment boundary is exactly the failure the issue reports: an
7770    // interrupted write. Each cut loses a different first segment and each
7771    // is caught by a different check, so all three are asserted — and the
7772    // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
7773    // what keeps these from passing against a parser that rejects
7774    // everything.
7775
7776    /// Everything from `at` (a segment header line) onward is gone.
7777    fn truncate_before(txt: &str, at: &str) -> String {
7778        let cut = txt
7779            .find(at)
7780            .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
7781        txt[..cut].to_string()
7782    }
7783
7784    #[test]
7785    fn truncation_before_the_row_bounds_is_a_parse_error() {
7786        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
7787            .expect_err("truncated file must not parse");
7788        assert!(
7789            err.contains("`r` (constraint-bounds) segment"),
7790            "error should name the missing segment: {err}"
7791        );
7792    }
7793
7794    #[test]
7795    fn truncation_before_the_variable_bounds_is_a_parse_error() {
7796        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
7797            .expect_err("truncated file must not parse");
7798        assert!(
7799            err.contains("`b` (variable-bounds) segment"),
7800            "error should name the missing segment: {err}"
7801        );
7802    }
7803
7804    /// The cut that leaves every bound in place and takes only the
7805    /// coefficients. Neither presence check can see it; the declared-vs-
7806    /// parsed nonzero count is the whole of the evidence.
7807    #[test]
7808    fn truncation_before_the_jacobian_is_a_parse_error() {
7809        let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
7810            .expect_err("truncated file must not parse");
7811        assert!(
7812            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
7813            "error should report the mismatch: {err}"
7814        );
7815    }
7816
7817    /// The mismatch is an equality, not a floor: a file supplying *more*
7818    /// entries than it declares is as corrupt as one supplying fewer, and a
7819    /// `>=` check would wave it through.
7820    #[test]
7821    fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
7822        let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
7823        let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
7824        assert!(
7825            err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
7826            "error should report the mismatch: {err}"
7827        );
7828    }
7829
7830    /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
7831    /// (n = m = 2) and hand back everything the engine would see.
7832    fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
7833        let mut obj = 1.0;
7834        let mut use_x = false;
7835        let mut x = vec![0.0; 2];
7836        let mut use_g = false;
7837        let mut g = vec![0.0; 2];
7838        let ok = tnlp.get_scaling_parameters(ScalingRequest {
7839            obj_scaling: &mut obj,
7840            use_x_scaling: &mut use_x,
7841            x_scaling: &mut x,
7842            use_g_scaling: &mut use_g,
7843            g_scaling: &mut g,
7844        });
7845        (ok, obj, use_x, x, use_g, g)
7846    }
7847
7848    /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
7849    /// the objective (`S6`) and one constraint (`S5`) reaches the
7850    /// engine's `user-scaling` pathway. The untagged second row is
7851    /// unscaled — its AMPL suffix default is 0, which is not a usable
7852    /// scale factor and reads as "not tagged".
7853    #[test]
7854    fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
7855        let nl = WITH_CON_SUFFIX.to_string()
7856            + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
7857        let p = parse_nl_text(&nl).expect("parse");
7858        let mut tnlp = NlTnlp::new(p);
7859        let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
7860        assert!(ok, "a tagged model must supply scaling");
7861        assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
7862        assert!(use_g);
7863        assert_eq!(g, vec![10.0, 1.0]);
7864        assert!(!use_x, "no variable suffix was declared");
7865    }
7866
7867    /// Variable-level `scaling_factor` entries are passed through, not
7868    /// dropped on the floor: `OrigIpoptNlp` does not model them and
7869    /// refuses the solve, which is the whole point of gh#483.
7870    #[test]
7871    fn scaling_factor_suffix_forwards_variable_factors() {
7872        let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
7873        let p = parse_nl_text(&nl).expect("parse");
7874        let mut tnlp = NlTnlp::new(p);
7875        let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
7876        assert!(ok);
7877        assert!(use_x, "variable factors must reach the engine");
7878        assert_eq!(x, vec![1.0, 3.0]);
7879    }
7880
7881    /// gh #703: with curvature-based scaling switched on, the computed
7882    /// factors are the base and a `scaling_factor` suffix the model
7883    /// actually carries wins **component by component** — an explicit
7884    /// factor from the modeller beats one inferred from the coefficients,
7885    /// and the components they did not tag keep the inferred one rather
7886    /// than snapping back to 1.
7887    #[test]
7888    fn a_user_suffix_overrides_the_computed_factor_component_wise() {
7889        let baseline = {
7890            let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
7891            let mut t = NlTnlp::new(p);
7892            assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
7893            scaling_of(&mut t).5
7894        };
7895        assert!(
7896            baseline.iter().all(|v| *v > 0.0),
7897            "curvature scaling should produce usable row factors, got {baseline:?}"
7898        );
7899
7900        // Tag row 0 only. AMPL's untagged default is 0, which must read as
7901        // "not tagged" and leave row 1 on the computed factor.
7902        let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
7903        let p = parse_nl_text(&nl).expect("parse");
7904        let mut t = NlTnlp::new(p);
7905        assert!(t.enable_curvature_scaling());
7906        let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
7907        assert!(ok);
7908        assert!(use_g && use_x);
7909        assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
7910        assert_eq!(
7911            g[1], baseline[1],
7912            "the untagged row keeps the computed one, not 1.0"
7913        );
7914    }
7915
7916    /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
7917    /// same answer the default `TNLP` impl gives, so `user-scaling`
7918    /// falls back to no scaling instead of a bogus all-zero vector.
7919    #[test]
7920    fn no_scaling_factor_suffix_declines() {
7921        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
7922        let mut tnlp = NlTnlp::new(p);
7923        let (ok, ..) = scaling_of(&mut tnlp);
7924        assert!(!ok);
7925    }
7926
7927    #[test]
7928    fn rejects_suffix_with_out_of_range_index() {
7929        let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
7930        let err = parse_nl_text(&bad).expect_err("must reject");
7931        assert!(
7932            err.contains("out of range"),
7933            "expected out-of-range error, got: {err}"
7934        );
7935    }
7936
7937    #[test]
7938    fn tnlp_round_trip_solves() {
7939        let p = parse_nl_text(SIMPLE).expect("parse");
7940        let mut tnlp = NlTnlp::new(p);
7941        let info = tnlp.get_nlp_info().unwrap();
7942        assert_eq!(info.n, 2);
7943        assert_eq!(info.m, 0);
7944        let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
7945        assert!((f0 - 5.0).abs() < 1e-12);
7946        let mut g = [0.0_f64; 2];
7947        tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
7948        // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
7949        assert!((g[0] - (-2.0)).abs() < 1e-12);
7950        assert!((g[1] - (-4.0)).abs() < 1e-12);
7951    }
7952
7953    // ---- Sibling `.col` / `.row` name-file capture --------------------
7954    //
7955    // Names let diagnostics name the offending equation instead of "row 3"
7956    // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
7957    // the read path and the documented fallback-to-empty behavior.
7958
7959    use pounce_nlp::expression_provider::ExpressionProvider;
7960    use std::sync::atomic::{AtomicUsize, Ordering};
7961
7962    /// Unique scratch dir for one test (no `tempfile` dev-dep available).
7963    fn scratch_dir(tag: &str) -> std::path::PathBuf {
7964        static N: AtomicUsize = AtomicUsize::new(0);
7965        let seq = N.fetch_add(1, Ordering::Relaxed);
7966        let dir = std::env::temp_dir().join(format!(
7967            "pounce_nlnames_{}_{}_{}",
7968            std::process::id(),
7969            tag,
7970            seq
7971        ));
7972        std::fs::create_dir_all(&dir).expect("create scratch dir");
7973        dir
7974    }
7975
7976    #[test]
7977    fn read_name_file_reads_in_order() {
7978        let dir = scratch_dir("col_order");
7979        let p = dir.join("m.col");
7980        std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
7981        assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
7982    }
7983
7984    #[test]
7985    fn read_name_file_truncates_extra_lines() {
7986        // `.row` conventionally appends the objective name after the m
7987        // constraint names; `.take(expected)` must drop it so names stay
7988        // 1:1 with `g`.
7989        let dir = scratch_dir("row_obj");
7990        let p = dir.join("m.row");
7991        std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
7992        assert_eq!(
7993            read_name_file(&p, 2),
7994            vec!["mass_balance", "energy_balance"]
7995        );
7996    }
7997
7998    #[test]
7999    fn read_name_file_empty_on_short_or_missing() {
8000        let dir = scratch_dir("short");
8001        let short = dir.join("m.col");
8002        std::fs::write(&short, "only_one\n").unwrap();
8003        // Fewer lines than expected ⇒ empty (never a partial mapping).
8004        assert!(read_name_file(&short, 3).is_empty());
8005        // Missing file ⇒ empty, no error.
8006        assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
8007    }
8008
8009    #[test]
8010    fn read_nl_file_captures_sibling_names() {
8011        // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
8012        // names ride through onto the TNLP's ExpressionProvider.
8013        let dir = scratch_dir("sibling");
8014        let nl = dir.join("m.nl");
8015        std::fs::write(&nl, SIMPLE).unwrap();
8016        std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
8017
8018        let prob = read_nl_file(&nl).expect("parse + name capture");
8019        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
8020        assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
8021
8022        let tnlp = NlTnlp::new(prob);
8023        assert_eq!(tnlp.variable_name(0), Some("alpha"));
8024        assert_eq!(tnlp.variable_name(1), Some("beta"));
8025        assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
8026    }
8027
8028    #[test]
8029    fn read_nl_file_without_names_yields_empty() {
8030        let dir = scratch_dir("noname");
8031        let nl = dir.join("m.nl");
8032        std::fs::write(&nl, SIMPLE).unwrap();
8033        let prob = read_nl_file(&nl).expect("parse");
8034        assert!(prob.var_names.is_empty());
8035        assert!(prob.con_names.is_empty());
8036        let tnlp = NlTnlp::new(prob);
8037        assert_eq!(tnlp.variable_name(0), None);
8038    }
8039
8040    #[test]
8041    fn read_nl_file_resolves_extensionless_ampl_stub() {
8042        // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
8043        // the `.nl` extension; the solver must read `mystub.nl`. Code
8044        // review 2026-06 item M15.
8045        let dir = scratch_dir("stub");
8046        std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
8047        // Pass the extensionless stub — the file `mystub` does not exist.
8048        let stub = dir.join("mystub");
8049        assert!(!stub.exists(), "stub must be extensionless / absent");
8050        let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
8051        assert_eq!(prob.n, 2);
8052        assert_eq!(prob.m, 0);
8053
8054        // Sibling name files are still found off the resolved stem.
8055        std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
8056        let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
8057        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
8058    }
8059
8060    #[test]
8061    fn read_nl_file_prefers_exact_path_over_nl_sibling() {
8062        // An existing path is read verbatim — the `.nl` fallback only
8063        // kicks in when the literal path is missing, so a caller passing a
8064        // real file is never silently redirected to a `<file>.nl` sibling.
8065        let dir = scratch_dir("exact");
8066        // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
8067        std::fs::write(dir.join("data"), SIMPLE).unwrap();
8068        std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
8069        let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
8070        assert_eq!(prob.n, 2);
8071    }
8072
8073    #[test]
8074    fn append_extension_appends_rather_than_replaces() {
8075        use std::path::Path;
8076        assert_eq!(
8077            append_extension(Path::new("mystub"), "nl"),
8078            Path::new("mystub.nl")
8079        );
8080        // A stub that itself contains a dot keeps its stem (AMPL names it
8081        // `my.model.nl`, not `my.nl`).
8082        assert_eq!(
8083            append_extension(Path::new("my.model"), "nl"),
8084            Path::new("my.model.nl")
8085        );
8086    }
8087
8088    // ---- equation rendering (`print equation`) ----
8089
8090    fn names(v: &[&str]) -> Vec<String> {
8091        v.iter().map(|s| s.to_string()).collect()
8092    }
8093
8094    #[test]
8095    fn render_uses_variable_names_when_present() {
8096        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
8097        assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
8098        // Falls back to x[i] when names are absent.
8099        assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
8100    }
8101
8102    #[test]
8103    fn render_parenthesizes_by_precedence() {
8104        // (x0 + x1) * x2  must keep the parens around the sum.
8105        let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
8106        let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
8107        assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
8108
8109        // x0 + x1 * x2  needs no parens (mul binds tighter).
8110        let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
8111        let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
8112        assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
8113    }
8114
8115    #[test]
8116    fn render_subtraction_right_assoc_parens() {
8117        // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
8118        let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
8119        let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
8120        assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
8121    }
8122
8123    #[test]
8124    fn render_functions_and_pow() {
8125        let sq = Expr::Binary(
8126            BinOp::Pow,
8127            Box::new(Expr::Var(0)),
8128            Box::new(Expr::Const(2.0)),
8129        );
8130        let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
8131        assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
8132    }
8133
8134    #[test]
8135    fn render_linear_signs_are_tidy() {
8136        // 1*a - 2*b + c  (coef +1 omits the multiplier).
8137        let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
8138        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
8139    }
8140
8141    #[test]
8142    fn render_linear_skips_zero_coefficients() {
8143        // A 0 coefficient (a variable present only in the nonlinear part)
8144        // is dropped, not rendered as `0*x`.
8145        let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
8146        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
8147        // Leading term zero ⇒ the first emitted term still has no ` + `.
8148        let lin = vec![(0usize, 0.0), (1, 2.0)];
8149        assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
8150    }
8151
8152    #[test]
8153    fn render_sum_folds_negative_terms() {
8154        // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
8155        let sq = |i| {
8156            Expr::Binary(
8157                BinOp::Pow,
8158                Box::new(Expr::Var(i)),
8159                Box::new(Expr::Const(2.0)),
8160            )
8161        };
8162        let neg = |i| {
8163            Expr::Binary(
8164                BinOp::Mul,
8165                Box::new(Expr::Const(-1.0)),
8166                Box::new(Expr::Var(i)),
8167            )
8168        };
8169        let e = Expr::Sum(vec![
8170            sq(0),
8171            neg(1),
8172            Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
8173        ]);
8174        assert_eq!(
8175            render_expr(&e, &names(&["a", "b", "c"]), &[]),
8176            "a^2 - 1*b - c"
8177        );
8178    }
8179
8180    #[test]
8181    fn render_constraint_equation_forms() {
8182        // Build a 2-constraint problem by hand: an equality and a range.
8183        let mut prob = parse_nl_text(SIMPLE).unwrap();
8184        // Overwrite to a known small shape: 1 var, 2 cons.
8185        prob.n = 2;
8186        prob.m = 2;
8187        prob.var_names = names(&["mass_in", "mass_out"]);
8188        prob.con_names = names(&["balance", "window"]);
8189        prob.con_linear = vec![
8190            vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
8191            vec![(0, 1.0)],            // mass_in
8192        ];
8193        prob.con_nonlinear = vec![
8194            NlBody::Tree(Expr::Const(0.0)),
8195            NlBody::Tree(Expr::Const(0.0)),
8196        ];
8197        prob.g_l = vec![0.0, 0.0];
8198        prob.g_u = vec![0.0, 500.0];
8199
8200        assert_eq!(
8201            render_constraint_equation(&prob, 0),
8202            "mass_in - mass_out = 0"
8203        );
8204        assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
8205
8206        let all = render_all_constraint_equations(&prob);
8207        assert_eq!(all.len(), 2);
8208        assert_eq!(all[1], "0 <= mass_in <= 500");
8209    }
8210
8211    #[test]
8212    fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
8213        let mut prob = parse_nl_text(SIMPLE).unwrap();
8214        prob.n = 3;
8215        prob.m = 2;
8216        // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
8217        // Row 1: linear in x2 only → support {2}.
8218        prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
8219        prob.con_nonlinear = vec![
8220            NlBody::Tree(Expr::Binary(
8221                BinOp::Mul,
8222                Box::new(Expr::Var(0)),
8223                Box::new(Expr::Var(2)),
8224            )),
8225            NlBody::Tree(Expr::Const(0.0)),
8226        ];
8227        prob.g_l = vec![0.0, 0.0];
8228        prob.g_u = vec![0.0, 0.0];
8229
8230        let (irow, jcol) = constraint_jacobian_sparsity(&prob);
8231        // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
8232        assert_eq!(irow, vec![0, 0, 0, 1]);
8233        assert_eq!(jcol, vec![0, 1, 2, 2]);
8234    }
8235
8236    #[test]
8237    fn funcall_string_arg_with_hash_is_not_truncated() {
8238        // Code review L31: an AMPL string argument is a Hollerith literal
8239        // `h<len>:<chars>` whose content is exactly <len> bytes and may
8240        // legitimately contain '#' (e.g. a parameters-directory path). The
8241        // old parser ran strip_comment() over the line first, truncating
8242        // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
8243        let mut p = Parser::new("h3:a#b\n", false);
8244        match p.parse_funcall_arg().expect("parse hollerith arg") {
8245            FuncallArg::Str(s) => assert_eq!(s, "a#b"),
8246            other => panic!("expected Str, got {other:?}"),
8247        }
8248    }
8249
8250    #[test]
8251    fn funcall_string_arg_honors_declared_length() {
8252        // The declared `<len>` is authoritative: exactly that many bytes
8253        // after the ':' form the string; trailing content (here a real
8254        // ` # comment`) is not part of it.
8255        let mut p = Parser::new("h3:abc # trailing comment\n", false);
8256        match p.parse_funcall_arg().expect("parse hollerith arg") {
8257            FuncallArg::Str(s) => assert_eq!(s, "abc"),
8258            other => panic!("expected Str, got {other:?}"),
8259        }
8260    }
8261
8262    // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
8263    //
8264    // AMPL emits these in place of the general `o5` (OPPOW) when one operand
8265    // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
8266    // negative-base-safe constant-power lowering applies. The eval points below
8267    // are chosen to pin down BOTH the arity and the operand order: a swapped
8268    // `base`/`exp` (or treating `o82` as a different unary op) gives a
8269    // different number at these points, so each assertion is discriminating.
8270
8271    /// Parse a single expression `expr_src` with `n` variables in scope,
8272    /// driving the real `parse_opcode` path through `parse_expr`.
8273    fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
8274        let mut p = Parser::new(expr_src, false);
8275        p.n = n;
8276        p.parse_expr().expect("parse expression")
8277    }
8278
8279    #[test]
8280    fn opcode_o82_square_is_unary_pow_of_two() {
8281        // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
8282        let e = parse_one_expr(1, "o82\nv0\n");
8283        match &e {
8284            Expr::Binary(BinOp::Pow, base, exp) => {
8285                assert!(matches!(**base, Expr::Var(0)));
8286                match **exp {
8287                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
8288                    ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
8289                }
8290            }
8291            other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
8292        }
8293        // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
8294        // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
8295        assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
8296        assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
8297        // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
8298        let mut g = [0.0_f64; 1];
8299        grad_expr(&e, &[3.0], 1.0, &mut g);
8300        assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
8301        g[0] = 0.0;
8302        grad_expr(&e, &[-3.0], 1.0, &mut g);
8303        assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
8304    }
8305
8306    #[test]
8307    fn opcode_o81_const_exponent_is_base_pow_const() {
8308        // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
8309        let e = parse_one_expr(1, "o81\nv0\nn3\n");
8310        match &e {
8311            Expr::Binary(BinOp::Pow, base, exp) => {
8312                assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
8313                match **exp {
8314                    Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
8315                    ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
8316                }
8317            }
8318            other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
8319        }
8320        // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
8321        assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
8322        // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
8323        // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
8324        assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
8325        // gradient d/dx x^3 = 3x^2 = 12 at x=2.
8326        let mut g = [0.0_f64; 1];
8327        grad_expr(&e, &[2.0], 1.0, &mut g);
8328        assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
8329    }
8330
8331    #[test]
8332    fn opcode_o83_const_base_is_const_pow_exp() {
8333        // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
8334        let e = parse_one_expr(1, "o83\nn2\nv0\n");
8335        match &e {
8336            Expr::Binary(BinOp::Pow, base, exp) => {
8337                match **base {
8338                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
8339                    ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
8340                }
8341                assert!(
8342                    matches!(**exp, Expr::Var(0)),
8343                    "exponent must be the variable"
8344                );
8345            }
8346            other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
8347        }
8348        // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
8349        assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
8350        assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
8351        // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
8352        let mut g = [0.0_f64; 1];
8353        grad_expr(&e, &[3.0], 1.0, &mut g);
8354        assert!(
8355            (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
8356            "grad at 3 = {} (want {})",
8357            g[0],
8358            8.0 * 2.0_f64.ln()
8359        );
8360    }
8361
8362    #[test]
8363    fn power_specializations_agree_with_general_o5() {
8364        // Where both are defined, o81/o82/o83 must be numerically identical to
8365        // the general `o5` pow on the same operands — they are only routing
8366        // hints, not different math.
8367        let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
8368        let o82 = parse_one_expr(1, "o82\nv0\n");
8369        let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
8370        let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
8371        let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
8372        let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
8373        for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
8374            assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
8375            assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
8376            // 2^x is real for all x; compare across the same points.
8377            assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
8378        }
8379    }
8380
8381    #[test]
8382    fn power_opcodes_round_trip_through_parse_nl_text() {
8383        // End-to-end through the public entry point: `min x0^2 + x1^2` written
8384        // with o82 (square) parses and evaluates like its o5 twin. Reuses the
8385        // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
8386        let nl = SIMPLE.replace(
8387            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
8388            "o0\no82\nv0\no82\nv1\n",
8389        );
8390        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
8391        let p = parse_nl_text(&nl).expect("parse o82 objective");
8392        // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
8393        assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
8394        assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
8395    }
8396
8397    #[test]
8398    fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
8399        // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
8400        // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
8401        // (the negative-base-safe path) rather than a generic `powf`. The check
8402        // at a NEGATIVE base is the one that would break if o81 wrongly routed
8403        // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
8404        let nl = SIMPLE.replace(
8405            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
8406            "o0\no81\nv0\nn3\no81\nv1\nn3\n",
8407        );
8408        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
8409        let p = parse_nl_text(&nl).expect("parse o81 objective");
8410        let mut tnlp = NlTnlp::new(p);
8411        tnlp.get_nlp_info().unwrap();
8412        // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
8413        let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
8414        assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
8415        // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
8416        let mut g = [0.0_f64; 2];
8417        assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
8418        assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
8419        assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
8420    }
8421
8422    // ---- Shared-CSE constraint tape (issue #476) ----------------------
8423
8424    /// Three constraints over one `V` segment (a `.nl` *defined variable*),
8425    /// `V3 = 2*x0 + 3*x1`, referenced by all three:
8426    ///   C0: V3^2        C1: V3^3 + x2        C2: V3*x2
8427    /// `{BODY2}` is a substitution point so a variant can drop an opcode the
8428    /// hybrid path rejects into C2.
8429    const SHARED_CSE: &str = "g3 1 1 0
8430 3 3 1 0 0
8431 3 0
8432 0 0
8433 3 0 0
8434 0 0 0 1
8435 0 0 0 0 0
8436 8 3
8437 0 0
8438 0 1 0 0 0
8439V3 2 0
84400 2.0
84411 3.0
8442n0
8443C0
8444o5
8445v3
8446n2
8447C1
8448o0
8449o5
8450v3
8451n3
8452v2
8453C2
8454{BODY2}
8455O0 0
8456n0
8457r
84582 0
84592 0
84602 0
8461b
84623
84633
84643
8465k2
84663
84676
8468J0 2
84690 0
84701 0
8471J1 3
84720 0
84731 0
84742 0
8475J2 3
84760 0
84771 0
84782 0
8479G0 3
84800 1.0
84811 1.0
84822 1.0
8483";
8484
8485    fn shared_cse_nl(body2: &str) -> String {
8486        SHARED_CSE.replace("{BODY2}", body2)
8487    }
8488
8489    /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
8490    /// per-summand tapes return — it is a different traversal of the same
8491    /// DAG, not a different derivative. Forced on here regardless of
8492    /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
8493    /// the size heuristic that decides when to use it.
8494    #[test]
8495    fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
8496        let nl = shared_cse_nl("o2\nv3\nv2");
8497        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
8498
8499        let mut hybrid = NlTnlp::new(p.clone());
8500        let info = hybrid.get_nlp_info().unwrap();
8501        let nnz = info.nnz_jac_g as usize;
8502        hybrid
8503            .con_hybrid
8504            .as_mut()
8505            .expect("CSE shared by 3 constraints must build the hybrid tape")
8506            .use_for_jac = true;
8507
8508        let mut flat = NlTnlp::new(p);
8509        flat.get_nlp_info().unwrap();
8510        flat.con_hybrid = None;
8511
8512        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
8513            let mut jh = vec![0.0_f64; nnz];
8514            let mut jf = vec![0.0_f64; nnz];
8515            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
8516            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
8517            assert_eq!(
8518                jh, jf,
8519                "hybrid Jacobian differs from the flat tape at {x:?}"
8520            );
8521
8522            // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
8523            let s = 2.0 * x[0] + 3.0 * x[1];
8524            let want = [
8525                4.0 * s,
8526                6.0 * s,
8527                6.0 * s * s,
8528                9.0 * s * s,
8529                1.0,
8530                2.0 * x[2],
8531                3.0 * x[2],
8532                s,
8533            ];
8534            assert_eq!(nnz, want.len());
8535            for k in 0..nnz {
8536                assert!(
8537                    (jh[k] - want[k]).abs() < 1e-9,
8538                    "entry {k} at {x:?}: got {}, want {}",
8539                    jh[k],
8540                    want[k]
8541                );
8542            }
8543        }
8544    }
8545
8546    /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
8547    /// CSE `S = body(x0, x1)` (the caller passes the body's expression
8548    /// text, which must reference exactly `v0` and `v1`). A deep shared
8549    /// body over few local ops per row is the regime the op-ratio gates
8550    /// are meant to catch: no repository fixture has a CSE shared across
8551    /// constraints at all, so without this the gate-on paths have no
8552    /// natural coverage.
8553    fn shared_body_chain_nl(m: usize, body: &str) -> String {
8554        let n = m + 2;
8555        let nzc = 3 * m;
8556        let mut s = String::new();
8557        s.push_str("g3 1 1 0\n");
8558        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
8559        s.push_str(&format!(" {m} 0\n 0 0\n"));
8560        s.push_str(&format!(" {n} 0 0\n"));
8561        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8562        s.push_str(&format!(" {nzc} {n}\n"));
8563        s.push_str(" 0 0\n 0 1 0 0 0\n");
8564        // The shared body.
8565        s.push_str(&format!("V{n} 0 0\n"));
8566        s.push_str(body);
8567        // Rows: S * x_{i+2}.
8568        for i in 0..m {
8569            s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
8570        }
8571        s.push_str("O0 0\nn0\n");
8572        s.push_str(&format!("x{n}\n"));
8573        for j in 0..n {
8574            s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
8575        }
8576        s.push_str("r\n");
8577        for _ in 0..m {
8578            s.push_str("2 0\n");
8579        }
8580        s.push_str("b\n");
8581        for _ in 0..n {
8582            s.push_str("3\n");
8583        }
8584        // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
8585        s.push_str(&format!("k{}\n", n - 1));
8586        let mut acc = 0;
8587        for j in 0..n - 1 {
8588            acc += if j < 2 { m } else { 1 };
8589            s.push_str(&format!("{acc}\n"));
8590        }
8591        for i in 0..m {
8592            s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
8593        }
8594        s.push_str(&format!("G0 {n}\n"));
8595        for j in 0..n {
8596            s.push_str(&format!("{j} 0.0\n"));
8597        }
8598        s
8599    }
8600
8601    /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
8602    /// The forward value saturates to `inf` past depth ≈ 5, which the
8603    /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
8604    /// bounded variant below instead, whose second-order terms would
8605    /// otherwise mix `inf`s of both signs into `NaN`.
8606    fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
8607        let mut body = String::new();
8608        for _ in 0..depth {
8609            body.push_str("o44\n");
8610        }
8611        body.push_str("o2\nn0.01\no0\nv0\nv1\n");
8612        shared_body_chain_nl(m, &body)
8613    }
8614
8615    /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
8616    /// (x0 + x1))` — mathematically the identity chain, so the value
8617    /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
8618    /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
8619    /// through both second-order sweeps. `2 * pairs` body ops drive the
8620    /// flat/shared op ratio as high as the Hessian gate tests need.
8621    fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
8622        let mut body = String::new();
8623        for _ in 0..pairs {
8624            body.push_str("o43\no44\n");
8625        }
8626        body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
8627        shared_body_chain_nl(m, &body)
8628    }
8629
8630    /// With a deep shared body the gate turns itself on, and the path it
8631    /// turns on must still agree with the flat tapes exactly.
8632    #[test]
8633    fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
8634        let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
8635
8636        let mut hybrid = NlTnlp::new(p.clone());
8637        let info = hybrid.get_nlp_info().unwrap();
8638        let nnz = info.nnz_jac_g as usize;
8639        assert!(
8640            hybrid
8641                .con_hybrid
8642                .as_ref()
8643                .expect("shared CSE must build the hybrid tape")
8644                .use_for_jac,
8645            "a 40-deep body shared by 16 rows is well past the op-ratio gate"
8646        );
8647
8648        let mut flat = NlTnlp::new(p);
8649        flat.get_nlp_info().unwrap();
8650        flat.con_hybrid = None;
8651
8652        for scale in [1.0_f64, -0.7, 2.5] {
8653            let x: Vec<f64> = (0..info.n as usize)
8654                .map(|j| scale * (0.2 + 0.03 * j as f64))
8655                .collect();
8656            let mut jh = vec![0.0_f64; nnz];
8657            let mut jf = vec![0.0_f64; nnz];
8658            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
8659            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
8660            assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
8661            assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
8662        }
8663    }
8664
8665    /// The Jacobian gate is off for a model whose shared bodies are small,
8666    /// because there the hybrid traversal's per-op overhead outweighs the
8667    /// halved forward sweep. `eval_g` still takes the hybrid path — that
8668    /// one is a win at any ratio.
8669    #[test]
8670    fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
8671        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
8672        let mut t = NlTnlp::new(p);
8673        t.get_nlp_info().unwrap();
8674        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
8675        assert!(
8676            !h.use_for_jac,
8677            "a 3-row model with a 2-term CSE is far below the op-ratio gate"
8678        );
8679    }
8680
8681    /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
8682    /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
8683    /// adjoints and running one prelude sweep reassociates floating-point
8684    /// products, so the two paths agree only to rounding on general inputs —
8685    /// but here every operation in both traversals is exact (dyadic values,
8686    /// small-integer coefficients, polynomial ops), so the results must be
8687    /// bit-identical, pinning the arithmetic itself and not just its
8688    /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
8689    /// path is covered independently of the size heuristic.
8690    #[test]
8691    fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
8692        let nl = shared_cse_nl("o2\nv3\nv2");
8693        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
8694
8695        let mut hybrid = NlTnlp::new(p.clone());
8696        let info = hybrid.get_nlp_info().unwrap();
8697        let nnz = info.nnz_h_lag as usize;
8698        hybrid
8699            .con_hybrid
8700            .as_mut()
8701            .expect("CSE shared by 3 constraints must build the hybrid tape")
8702            .use_for_hess = true;
8703
8704        let mut flat = NlTnlp::new(p);
8705        flat.get_nlp_info().unwrap();
8706        flat.con_hybrid = None;
8707
8708        let pairs: Vec<(usize, usize)> = hybrid
8709            .h_irow
8710            .iter()
8711            .zip(&hybrid.h_jcol)
8712            .map(|(&i, &j)| (i as usize, j as usize))
8713            .collect();
8714
8715        // Two multiplier sets: one all-live, one with a dead row so the
8716        // λ == 0 skip is exercised on the hybrid path too.
8717        for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
8718            for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
8719                let mut hh = vec![0.0_f64; nnz];
8720                let mut hf = vec![0.0_f64; nnz];
8721                assert!(hybrid.eval_h(
8722                    Some(&x),
8723                    true,
8724                    1.0,
8725                    Some(&lam),
8726                    true,
8727                    SparsityRequest::Values { values: &mut hh }
8728                ));
8729                assert!(flat.eval_h(
8730                    Some(&x),
8731                    true,
8732                    1.0,
8733                    Some(&lam),
8734                    true,
8735                    SparsityRequest::Values { values: &mut hf }
8736                ));
8737                assert_eq!(
8738                    hh, hf,
8739                    "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
8740                );
8741
8742                // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
8743                // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
8744                // objective is constant, so the Lagrangian Hessian is
8745                //   (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
8746                let s = 2.0 * x[0] + 3.0 * x[1];
8747                let q = 2.0 * lam[0] + 6.0 * s * lam[1];
8748                let dv = [2.0, 3.0, 0.0];
8749                for (k, &(i, j)) in pairs.iter().enumerate() {
8750                    let mut want = q * dv[i] * dv[j];
8751                    if i == 2 {
8752                        want += lam[2] * dv[j];
8753                    }
8754                    if j == 2 {
8755                        want += lam[2] * dv[i];
8756                    }
8757                    assert!(
8758                        (hh[k] - want).abs() < 1e-9,
8759                        "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
8760                        hh[k]
8761                    );
8762                }
8763            }
8764        }
8765    }
8766
8767    /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
8768    /// turns the Hessian gate on by itself, and the path it turns on must
8769    /// agree with the flat tapes. Not bitwise here: the shared prelude
8770    /// reverse sweep runs once over the `λ_k`-folded adjoints of all
8771    /// summands where the flat path runs per summand, and that
8772    /// reassociation moves transcendental results by rounding — so the bar
8773    /// is a relative few-ULP band, with the exact-arithmetic case pinned
8774    /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
8775    #[test]
8776    fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
8777        let m = 16;
8778        let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
8779
8780        let mut hybrid = NlTnlp::new(p.clone());
8781        let info = hybrid.get_nlp_info().unwrap();
8782        let nnz = info.nnz_h_lag as usize;
8783        assert!(
8784            hybrid
8785                .con_hybrid
8786                .as_ref()
8787                .expect("shared CSE must build the hybrid tape")
8788                .use_for_hess,
8789            "a 40-op body shared by 16 rows is well past the op-ratio gate"
8790        );
8791
8792        let mut flat = NlTnlp::new(p);
8793        flat.get_nlp_info().unwrap();
8794        flat.con_hybrid = None;
8795
8796        let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
8797        for scale in [1.0_f64, -0.7, 2.5] {
8798            let x: Vec<f64> = (0..info.n as usize)
8799                .map(|j| scale * (0.2 + 0.03 * j as f64))
8800                .collect();
8801            // Run the hybrid Jacobian first, the order a real solve
8802            // iteration uses. Its `gradient_summand` sweeps leave the
8803            // *Jacobian's* prelude adjoint arena dirty; the Hessian's
8804            // accumulators must be its own buffers with the all-zero-
8805            // between-colors invariant, or this seeds `eval_h` with a
8806            // stale row gradient (caught here).
8807            let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
8808            assert!(hybrid.eval_jac_g(
8809                Some(&x),
8810                true,
8811                SparsityRequest::Values { values: &mut jac }
8812            ));
8813            let mut hh = vec![0.0_f64; nnz];
8814            let mut hf = vec![0.0_f64; nnz];
8815            assert!(hybrid.eval_h(
8816                Some(&x),
8817                true,
8818                1.0,
8819                Some(&lam),
8820                true,
8821                SparsityRequest::Values { values: &mut hh }
8822            ));
8823            assert!(flat.eval_h(
8824                Some(&x),
8825                true,
8826                1.0,
8827                Some(&lam),
8828                true,
8829                SparsityRequest::Values { values: &mut hf }
8830            ));
8831            for k in 0..nnz {
8832                assert!(
8833                    hh[k].is_finite() && hf[k].is_finite(),
8834                    "non-finite Hessian entry {k} defeats the comparison"
8835                );
8836                let tol = 1e-12 * hf[k].abs().max(1.0);
8837                assert!(
8838                    (hh[k] - hf[k]).abs() <= tol,
8839                    "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
8840                    hh[k],
8841                    hf[k]
8842                );
8843            }
8844            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
8845        }
8846    }
8847
8848    /// `.nl` text for two independent shared-CSE blocks of different widths:
8849    /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
8850    /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
8851    /// of the form `body * x_r`.
8852    ///
8853    /// The width difference is the point. A body summing `w` variables gives
8854    /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
8855    /// conflict and the coloring must spend `w` colors on them; the narrower
8856    /// block reuses the low colors. The surplus colors therefore belong to
8857    /// block A alone, and a per-color prelude walk over *both* bodies would
8858    /// be doing work for a body that color cannot reach.
8859    fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
8860        let nvars = wide + narrow;
8861        let m = 2 * rows;
8862        let n = nvars + m;
8863        // Block A rows touch `wide` body vars + 1 row var; block B rows
8864        // touch `narrow` + 1.
8865        let nzc = rows * (wide + 1) + rows * (narrow + 1);
8866        let mut s = String::new();
8867        s.push_str("g3 1 1 0\n");
8868        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
8869        s.push_str(&format!(" {m} 0\n 0 0\n"));
8870        s.push_str(&format!(" {n} 0 0\n"));
8871        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8872        s.push_str(&format!(" {nzc} {n}\n"));
8873        s.push_str(" 0 0\n 0 2 0 0 0\n");
8874        // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
8875        // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
8876        for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
8877            s.push_str(&format!("V{} 0 0\n", n + b));
8878            s.push_str("o41\n");
8879            for _ in 0..count - 1 {
8880                s.push_str("o0\n");
8881            }
8882            for j in 0..*count {
8883                s.push_str(&format!("v{}\n", base + j));
8884            }
8885        }
8886        // Rows: body_b * x_{rowvar}.
8887        for i in 0..m {
8888            let b = i / rows;
8889            s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
8890        }
8891        s.push_str("O0 0\nn0\n");
8892        s.push_str(&format!("x{n}\n"));
8893        for j in 0..n {
8894            s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
8895        }
8896        s.push_str("r\n");
8897        for _ in 0..m {
8898            s.push_str("2 0\n");
8899        }
8900        s.push_str("b\n");
8901        for _ in 0..n {
8902            s.push_str("3\n");
8903        }
8904        // Cumulative Jacobian column counts for the first n-1 columns.
8905        s.push_str(&format!("k{}\n", n - 1));
8906        let mut acc = 0;
8907        for j in 0..n - 1 {
8908            acc += if j < nvars { rows } else { 1 };
8909            s.push_str(&format!("{acc}\n"));
8910        }
8911        for i in 0..m {
8912            let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
8913            s.push_str(&format!("J{i} {}\n", count + 1));
8914            let mut cols: Vec<usize> = (base..base + count).collect();
8915            cols.push(nvars + i);
8916            cols.sort_unstable();
8917            for c in cols {
8918                s.push_str(&format!("{c} 0.0\n"));
8919            }
8920        }
8921        s.push_str(&format!("G0 {n}\n"));
8922        for j in 0..n {
8923            s.push_str(&format!("{j} 0.0\n"));
8924        }
8925        s
8926    }
8927
8928    /// Both prelude sweeps run once per color, so iterating the whole prelude
8929    /// each time would cost `n_colors × |prelude|` where the op-ratio gate
8930    /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
8931    /// `eval_h` instead walks the union of that color's summands'
8932    /// `prelude_reach`.
8933    ///
8934    /// What this can and cannot pin is worth being exact about, because the
8935    /// change is pure performance: walking the whole prelude per color
8936    /// computes the *same* Hessian, so no assertion on output values can
8937    /// detect it, and a timing assertion would be flaky. So this asserts the
8938    /// two things that are checkable — that the table the sweeps iterate is
8939    /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
8940    /// where colors genuinely reach different bodies, and that each reach list
8941    /// is ascending and operand-closed, the invariants that make the narrowed
8942    /// walk safe — plus agreement with the flat tapes under narrowing.
8943    #[test]
8944    fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
8945        let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
8946        let mut hybrid = NlTnlp::new(p.clone());
8947        let info = hybrid.get_nlp_info().unwrap();
8948        let nnz = info.nnz_h_lag as usize;
8949        let m = info.m as usize;
8950
8951        {
8952            let h = hybrid
8953                .con_hybrid
8954                .as_mut()
8955                .expect("two shared CSE bodies must build the hybrid tape");
8956            h.use_for_hess = true;
8957
8958            let np = h.tape.n_prelude_ops();
8959            let n_colors = h.hess_color_reach_off.len() - 1;
8960            let total: usize = h.hess_color_reach.len();
8961            assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
8962            assert!(
8963                total < n_colors * np,
8964                "per-color reach must be strictly smaller than walking the whole \
8965                 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
8966                n_colors * np
8967            );
8968            // Every reach list must be ascending and operand-closed, which is
8969            // what makes the narrowed walk safe.
8970            for c in 0..n_colors {
8971                let r =
8972                    &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
8973                assert!(
8974                    r.windows(2).all(|w| w[0] < w[1]),
8975                    "color {c} reach is not strictly ascending"
8976                );
8977                let member: std::collections::HashSet<u32> = r.iter().copied().collect();
8978                for &i in r {
8979                    let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
8980                    for opnd in [a, b].into_iter().flatten() {
8981                        assert!(
8982                            member.contains(&(opnd as u32)),
8983                            "color {c}: slot {i}'s operand {opnd} is missing from its reach"
8984                        );
8985                    }
8986                }
8987            }
8988        }
8989
8990        let mut flat = NlTnlp::new(p);
8991        flat.get_nlp_info().unwrap();
8992        flat.con_hybrid = None;
8993
8994        let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
8995        for scale in [1.0_f64, -0.6] {
8996            let x: Vec<f64> = (0..info.n as usize)
8997                .map(|j| scale * (0.15 + 0.02 * j as f64))
8998                .collect();
8999            let mut hh = vec![0.0_f64; nnz];
9000            let mut hf = vec![0.0_f64; nnz];
9001            assert!(hybrid.eval_h(
9002                Some(&x),
9003                true,
9004                1.0,
9005                Some(&lam),
9006                true,
9007                SparsityRequest::Values { values: &mut hh }
9008            ));
9009            assert!(flat.eval_h(
9010                Some(&x),
9011                true,
9012                1.0,
9013                Some(&lam),
9014                true,
9015                SparsityRequest::Values { values: &mut hf }
9016            ));
9017            for k in 0..nnz {
9018                let tol = 1e-12 * hf[k].abs().max(1.0);
9019                assert!(
9020                    (hh[k] - hf[k]).abs() <= tol,
9021                    "narrowed-reach Hessian entry {k} at scale {scale}: \
9022                     hybrid {} vs flat {}",
9023                    hh[k],
9024                    hf[k]
9025                );
9026            }
9027            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
9028        }
9029    }
9030
9031    /// The Hessian gate is off for a model whose shared bodies are small —
9032    /// below the ratio where the shared prelude sweeps pay for the hybrid
9033    /// traversal's per-op overhead — leaving `eval_h` on the flat path
9034    /// (which the gate keeps bit-identical for such models by definition).
9035    #[test]
9036    fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
9037        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
9038        let mut t = NlTnlp::new(p);
9039        t.get_nlp_info().unwrap();
9040        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
9041        assert!(
9042            !h.use_for_hess,
9043            "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
9044        );
9045    }
9046
9047    /// A CSE referenced from several constraints is evaluated once per
9048    /// `eval_g` via the shared prelude instead of once per reference. The
9049    /// values must be bit-identical to the flat per-summand `Tape` path,
9050    /// which is what makes the optimization safe to apply unconditionally.
9051    #[test]
9052    fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
9053        let nl = shared_cse_nl("o2\nv3\nv2");
9054        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9055        let mut hybrid = NlTnlp::new(p.clone());
9056        hybrid.get_nlp_info().unwrap();
9057        let h = hybrid
9058            .con_hybrid
9059            .as_ref()
9060            .expect("CSE shared by 3 constraints must take the hybrid path");
9061        assert!(
9062            h.tape.n_prelude_ops() > 0,
9063            "shared CSE body must land in the prelude"
9064        );
9065
9066        // Same model with the hybrid path switched off: the reference.
9067        let mut flat = NlTnlp::new(p);
9068        flat.get_nlp_info().unwrap();
9069        flat.con_hybrid = None;
9070
9071        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9072            let mut gh = [0.0_f64; 3];
9073            let mut gf = [0.0_f64; 3];
9074            assert!(hybrid.eval_g(&x, true, &mut gh));
9075            assert!(flat.eval_g(&x, true, &mut gf));
9076            // V3 = 2 x0 + 3 x1.
9077            let s = 2.0 * x[0] + 3.0 * x[1];
9078            let want = [s * s, s * s * s + x[2], s * x[2]];
9079            for i in 0..3 {
9080                assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
9081                assert!(
9082                    (gh[i] - want[i]).abs() < 1e-9,
9083                    "row {i}: got {}, want {}",
9084                    gh[i],
9085                    want[i]
9086                );
9087            }
9088        }
9089    }
9090
9091    /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
9092    /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
9093    /// take that path after `hybrid_supported` clears the model. Here a
9094    /// min-list in one constraint has to disable it for the whole block —
9095    /// falling back, not panicking.
9096    #[test]
9097    fn unsupported_opcode_falls_back_to_the_flat_tape() {
9098        let nl = shared_cse_nl("o11\n2\nv3\nv2");
9099        let p = parse_nl_text(&nl).expect("parse min-list model");
9100        let mut tnlp = NlTnlp::new(p);
9101        tnlp.get_nlp_info().unwrap();
9102        assert!(
9103            tnlp.con_hybrid.is_none(),
9104            "a min-list anywhere in the constraint block must disable the hybrid path"
9105        );
9106        let mut g = [0.0_f64; 3];
9107        assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
9108        let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
9109        assert!((g[0] - s * s).abs() < 1e-9);
9110        assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
9111    }
9112
9113    // ---- In-memory construction + HVP (issue #469) --------------------
9114
9115    fn v(i: usize) -> Expr {
9116        Expr::Var(i)
9117    }
9118
9119    fn c(x: Number) -> Expr {
9120        Expr::Const(x)
9121    }
9122
9123    fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
9124        Expr::Binary(op, Box::new(a), Box::new(b))
9125    }
9126
9127    fn un(op: UnaryOp, a: Expr) -> Expr {
9128        Expr::Unary(op, Box::new(a))
9129    }
9130
9131    /// `NlProblemParts` for an `n`-variable, unbounded model.
9132    fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
9133        let m = constraints.len();
9134        NlProblemParts {
9135            minimize: true,
9136            objective,
9137            obj_constant: 0.0,
9138            constraints,
9139            x_l: vec![-1e19; n],
9140            x_u: vec![1e19; n],
9141            x0: vec![0.0; n],
9142            g_l: vec![-1e19; m],
9143            g_u: vec![1e19; m],
9144            var_names: Vec::new(),
9145            con_names: Vec::new(),
9146        }
9147    }
9148
9149    /// A model built from expressions evaluates exactly like a parsed one:
9150    /// objective, gradient, constraints, and Jacobian all come from the
9151    /// same tape, with no `.nl` text in the loop.
9152    #[test]
9153    fn from_expressions_builds_evaluable_problem() {
9154        // min (1-x0)^2 + 100*(x1 - x0^2)^2   s.t.  x0^2 + x1^2 <= 2
9155        let rosen = bin(
9156            BinOp::Add,
9157            bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
9158            bin(
9159                BinOp::Mul,
9160                c(100.0),
9161                bin(
9162                    BinOp::Pow,
9163                    bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
9164                    c(2.0),
9165                ),
9166            ),
9167        );
9168        let circle = bin(
9169            BinOp::Add,
9170            bin(BinOp::Pow, v(0), c(2.0)),
9171            bin(BinOp::Pow, v(1), c(2.0)),
9172        );
9173
9174        let mut p = parts(2, rosen, vec![circle]);
9175        p.g_l = vec![0.0];
9176        p.g_u = vec![2.0];
9177        p.x0 = vec![-1.2, 1.0];
9178        p.var_names = names(&["x", "y"]);
9179        p.con_names = names(&["circle"]);
9180
9181        let prob = NlProblem::from_expressions(p).expect("build");
9182        assert_eq!((prob.n, prob.m), (2, 1));
9183        assert_eq!(prob.var_names, names(&["x", "y"]));
9184
9185        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9186        t.get_nlp_info().unwrap();
9187
9188        // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
9189        let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
9190        assert!((f - 24.2).abs() < 1e-10, "f = {f}");
9191
9192        // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
9193        //    = (4.4 + 480*(-0.44)... ) — computed below rather than
9194        //      transcribed, so the check is the formula, not an editor.
9195        let (x0, x1) = (-1.2, 1.0);
9196        let want = [
9197            -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
9198            200.0 * (x1 - x0 * x0),
9199        ];
9200        let mut g = [0.0_f64; 2];
9201        assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
9202        for j in 0..2 {
9203            assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
9204        }
9205
9206        // g(x) = x0^2 + x1^2 = 2.44
9207        let mut gv = [0.0_f64; 1];
9208        assert!(t.eval_g(&[x0, x1], true, &mut gv));
9209        assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
9210    }
9211
9212    /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
9213    /// this path. None of the three survives a `.nl` round trip in a
9214    /// typical frontend — `atan2` has no two-argument funcall path,
9215    /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
9216    /// at all — which is the reason the in-memory door exists.
9217    #[test]
9218    fn from_expressions_carries_ops_nl_cannot_express() {
9219        let obj = Expr::Sum(vec![
9220            bin(BinOp::Atan2, v(0), v(1)),
9221            Expr::MinList(vec![v(0), v(1)]),
9222            Expr::MaxList(vec![v(0), v(1)]),
9223            un(UnaryOp::Erf, v(0)),
9224        ]);
9225        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
9226        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9227        t.get_nlp_info().unwrap();
9228
9229        let x: [Number; 2] = [0.8, 1.5];
9230        // atan2 + min + max + erf; min+max == x0+x1 for any pair.
9231        let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
9232        let f = t.eval_f(&x, true).unwrap();
9233        assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
9234    }
9235
9236    /// A `Var` index past `n` would be an out-of-bounds read in the
9237    /// tape's forward sweep. It has to be caught at construction, while
9238    /// it is still a diagnosable user error.
9239    #[test]
9240    fn from_expressions_rejects_out_of_range_var() {
9241        let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
9242            .expect_err("Var(5) with n = 2 must be rejected");
9243        assert!(err.contains("Var(5)"), "{err}");
9244
9245        let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
9246            .expect_err("constraint Var(2) with n = 2 must be rejected");
9247        assert!(err.contains("constraint 0"), "{err}");
9248
9249        // Length mismatches are errors too, not panics.
9250        let mut p = parts(2, c(0.0), Vec::new());
9251        p.x0 = vec![0.0; 3];
9252        let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
9253        assert!(err.contains("x0"), "{err}");
9254    }
9255
9256    /// An out-of-range `Var` must be caught wherever it hides, not just at
9257    /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
9258    /// branch, and a funcall argument all reach the same forward sweep.
9259    #[test]
9260    fn from_expressions_finds_out_of_range_vars_in_every_position() {
9261        let inner_cse = Arc::new(v(7));
9262        let cases: Vec<(&str, Expr)> = vec![
9263            ("bare", v(7)),
9264            ("cse", Expr::Cse(Arc::new(v(7)))),
9265            ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
9266            (
9267                "cond branch",
9268                Expr::Cond {
9269                    cond: Box::new(c(1.0)),
9270                    then_: Box::new(v(7)),
9271                    else_: Box::new(c(0.0)),
9272                },
9273            ),
9274            ("min list", Expr::MinList(vec![c(0.0), v(7)])),
9275            (
9276                "sum",
9277                Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
9278            ),
9279        ];
9280        for (label, e) in cases {
9281            let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
9282                .err()
9283                .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
9284            assert!(err.contains("Var(7)"), "{label}: {err}");
9285        }
9286    }
9287
9288    /// A balanced share-DAG: each level is one `Cse` whose body
9289    /// references the level below twice. Depth `d` is `d` distinct nodes
9290    /// but `2^d` paths, so any walk that re-enters a shared body per
9291    /// occurrence is Θ(2^d).
9292    fn share_dag(depth: usize) -> Expr {
9293        let mut e = v(0);
9294        for _ in 0..depth {
9295            let shared = Arc::new(e);
9296            e = bin(
9297                BinOp::Add,
9298                Expr::Cse(Arc::clone(&shared)),
9299                Expr::Cse(shared),
9300            );
9301        }
9302        e
9303    }
9304
9305    /// The walks over a shared DAG must be memoized, not exponential.
9306    ///
9307    /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
9308    /// does not "fail" so much as never finish, which is exactly the
9309    /// signal. `from_expressions` is the door that makes such a DAG
9310    /// trivially constructible, but the blowup was reachable through
9311    /// `collect_vars` (which presolve calls on every solve, via
9312    /// `get_variables_linearity`) and `collect_funcall_ids` (which
9313    /// `NlTnlp::try_new` runs over every row).
9314    #[test]
9315    fn shared_dag_walks_are_memoized_not_exponential() {
9316        const DEPTH: usize = 30;
9317        let e = share_dag(DEPTH);
9318
9319        let mut vars = BTreeSet::new();
9320        collect_vars(&e, &mut vars);
9321        assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
9322
9323        let mut ids = BTreeSet::new();
9324        super::super::nl_external::collect_funcall_ids(&e, &mut ids);
9325        assert!(ids.is_empty());
9326
9327        // The whole build path, end to end: validation, tape construction,
9328        // and the linearity metadata presolve consumes.
9329        let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
9330        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9331        t.get_nlp_info().unwrap();
9332        let mut lin = vec![Linearity::Linear; 1];
9333        assert!(t.get_variables_linearity(&mut lin));
9334    }
9335
9336    /// `from_expressions` cannot carry AMPL imported functions — there is
9337    /// nowhere to put the `F`-segment declarations that bind a funcall id
9338    /// to a library — so a `Funcall` must be refused up front. Accepting it
9339    /// produces "AMPLFUNC is not set", which the user cannot act on:
9340    /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
9341    #[test]
9342    fn from_expressions_rejects_imported_function_calls() {
9343        let call = Expr::Funcall {
9344            id: 0,
9345            args: vec![FuncallArg::Real(v(0))],
9346        };
9347        let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
9348            .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
9349        assert!(err.contains("imported function"), "{err}");
9350        assert!(
9351            err.contains("read_nl") || err.contains("parse_nl_text"),
9352            "the error must point at the paths that do support externals: {err}"
9353        );
9354
9355        // Also when buried in a constraint, behind a Cse.
9356        let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
9357        let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
9358            .expect_err("a buried Funcall must be rejected too");
9359        assert!(err.contains("constraint 0"), "{err}");
9360    }
9361
9362    /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
9363    /// same tapes, same weights, one seed instead of a color sweep. The
9364    /// objective and both constraints are chosen with cross terms so the
9365    /// off-diagonal blocks actually carry signal.
9366    #[test]
9367    fn hessian_vector_product_matches_dense_hessian() {
9368        let obj = Expr::Sum(vec![
9369            bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
9370            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
9371            un(UnaryOp::Erf, v(2)),
9372        ]);
9373        let cons = vec![
9374            bin(
9375                BinOp::Add,
9376                bin(BinOp::Pow, v(0), c(2.0)),
9377                un(UnaryOp::Sin, v(2)),
9378            ),
9379            bin(BinOp::Mul, v(1), v(2)),
9380        ];
9381        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
9382        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9383        let info = t.get_nlp_info().unwrap();
9384
9385        let x = [0.3, -0.7, 1.1];
9386        let lam = [0.5, -1.25];
9387        let obj_factor = 2.0;
9388
9389        // Dense Hessian from the sparse lower triangle.
9390        let nnz = info.nnz_h_lag as usize;
9391        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
9392        assert!(t.eval_h(
9393            None,
9394            false,
9395            1.0,
9396            None,
9397            false,
9398            SparsityRequest::Structure {
9399                irow: &mut irow,
9400                jcol: &mut jcol
9401            }
9402        ));
9403        let mut hvals = vec![0.0_f64; nnz];
9404        assert!(t.eval_h(
9405            Some(&x),
9406            true,
9407            obj_factor,
9408            Some(&lam),
9409            true,
9410            SparsityRequest::Values { values: &mut hvals }
9411        ));
9412        let mut dense = [[0.0_f64; 3]; 3];
9413        for k in 0..nnz {
9414            let (i, j) = (irow[k] as usize, jcol[k] as usize);
9415            dense[i][j] += hvals[k];
9416            if i != j {
9417                dense[j][i] += hvals[k];
9418            }
9419        }
9420
9421        // Each unit seed recovers a column; a mixed seed catches an HVP
9422        // that only happens to be right on the basis vectors.
9423        let seeds: [[Number; 3]; 4] = [
9424            [1.0, 0.0, 0.0],
9425            [0.0, 1.0, 0.0],
9426            [0.0, 0.0, 1.0],
9427            [0.4, -1.3, 2.0],
9428        ];
9429        let mut out = vec![0.0; 3];
9430        for s in &seeds {
9431            t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
9432                .expect("hvp");
9433            for i in 0..3 {
9434                let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
9435                assert!(
9436                    (out[i] - want).abs() < 1e-9,
9437                    "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
9438                    out[i]
9439                );
9440            }
9441        }
9442    }
9443
9444    /// `lam = None` is the objective block alone, and `out` is
9445    /// overwritten (not accumulated) so a reused buffer is safe.
9446    #[test]
9447    fn hessian_vector_product_defaults_and_validation() {
9448        // f = x0^2 + 3 x0 x1  ->  ∇²f = [[2, 3], [3, 0]]
9449        let obj = bin(
9450            BinOp::Add,
9451            bin(BinOp::Pow, v(0), c(2.0)),
9452            bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
9453        );
9454        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
9455        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9456        t.get_nlp_info().unwrap();
9457
9458        let mut out = vec![7.0, -7.0]; // dirty buffer
9459        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
9460            .expect("hvp");
9461        assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
9462        assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
9463
9464        // obj_factor scales linearly.
9465        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
9466            .expect("hvp");
9467        assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
9468
9469        // Length mismatches are errors, not panics or silent truncation.
9470        let mut short = vec![0.0; 1];
9471        assert!(
9472            t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
9473                .is_err()
9474        );
9475        assert!(
9476            t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
9477                .is_err()
9478        );
9479        assert!(
9480            t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
9481                .is_err()
9482        );
9483    }
9484
9485    /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
9486    /// Hessian — the sparse shape an IPM actually meets. The block HVP has
9487    /// to reproduce it column for column, including the structural zeros:
9488    /// a bug that leaked coupling between non-adjacent variables would
9489    /// show up here and nowhere in a small dense test.
9490    #[test]
9491    fn hessian_vector_products_on_a_sparse_hessian() {
9492        const N: usize = 8;
9493        let mut terms = Vec::new();
9494        for i in 0..N - 1 {
9495            terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
9496        }
9497        for i in 0..N {
9498            terms.push(un(UnaryOp::Exp, v(i)));
9499        }
9500        let prob =
9501            NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
9502        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9503        let info = t.get_nlp_info().unwrap();
9504
9505        // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
9506        assert_eq!(
9507            info.nnz_h_lag as usize,
9508            2 * N - 1,
9509            "chain objective should give a tridiagonal Hessian, not a dense one"
9510        );
9511
9512        let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
9513
9514        // Densify the sparse triangle for the reference.
9515        let nnz = info.nnz_h_lag as usize;
9516        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
9517        assert!(t.eval_h(
9518            None,
9519            false,
9520            1.0,
9521            None,
9522            false,
9523            SparsityRequest::Structure {
9524                irow: &mut irow,
9525                jcol: &mut jcol
9526            }
9527        ));
9528        let mut hvals = vec![0.0; nnz];
9529        assert!(t.eval_h(
9530            Some(&x),
9531            true,
9532            1.0,
9533            None,
9534            true,
9535            SparsityRequest::Values { values: &mut hvals }
9536        ));
9537        let mut dense = vec![vec![0.0; N]; N];
9538        for k in 0..nnz {
9539            let (i, j) = (irow[k] as usize, jcol[k] as usize);
9540            dense[i][j] += hvals[k];
9541            if i != j {
9542                dense[j][i] += hvals[k];
9543            }
9544        }
9545
9546        // One block of N unit seeds recovers the whole matrix — the
9547        // "densify via HVPs" path, in one call.
9548        let mut seeds = vec![0.0; N * N];
9549        for cc in 0..N {
9550            seeds[cc * N + cc] = 1.0;
9551        }
9552        let mut out = vec![0.0; N * N];
9553        t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
9554            .expect("block hvp");
9555        for cc in 0..N {
9556            for i in 0..N {
9557                assert!(
9558                    (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
9559                    "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
9560                    out[cc * N + i],
9561                    dense[i][cc]
9562                );
9563            }
9564        }
9565    }
9566
9567    /// The block form must agree with `k` separate single-vector calls
9568    /// (it shares one forward sweep across directions, so a bug there
9569    /// would show as a per-direction discrepancy), and must skip an
9570    /// all-zero direction without disturbing its neighbours.
9571    #[test]
9572    fn hessian_vector_products_match_repeated_single_calls() {
9573        let obj = Expr::Sum(vec![
9574            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
9575            bin(BinOp::Pow, v(2), c(4.0)),
9576            bin(BinOp::Mul, v(0), v(2)),
9577        ]);
9578        let cons = vec![bin(BinOp::Mul, v(1), v(2))];
9579        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
9580        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9581        t.get_nlp_info().unwrap();
9582
9583        let x = [0.4, -0.6, 1.3];
9584        let lam = [0.75];
9585        let cols: [[Number; 3]; 4] = [
9586            [1.0, 2.0, -3.0],
9587            [0.0, 0.0, 0.0], // the skipped direction
9588            [0.5, 0.0, 0.0],
9589            [-1.0, 1.0, 1.0],
9590        ];
9591
9592        let mut block = vec![0.0; 3 * cols.len()];
9593        let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
9594        t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
9595            .expect("block hvp");
9596
9597        for (c, col) in cols.iter().enumerate() {
9598            let mut single = vec![0.0; 3];
9599            t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
9600                .expect("single hvp");
9601            for i in 0..3 {
9602                assert!(
9603                    (block[c * 3 + i] - single[i]).abs() < 1e-12,
9604                    "direction {c} row {i}: block={:.12e} single={:.12e}",
9605                    block[c * 3 + i],
9606                    single[i]
9607                );
9608            }
9609        }
9610        // The zero direction really is zero, not stale scratch.
9611        assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
9612    }
9613
9614    /// `k = 0` is a legal empty block, and the length checks scale with
9615    /// `k` rather than assuming a single direction.
9616    #[test]
9617    fn hessian_vector_products_validate_block_shape() {
9618        let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
9619            .expect("build");
9620        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9621        t.get_nlp_info().unwrap();
9622
9623        let mut empty: Vec<Number> = Vec::new();
9624        assert!(
9625            t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
9626                .is_ok()
9627        );
9628
9629        // v sized for one direction while k says two.
9630        let mut out = vec![0.0; 4];
9631        assert!(
9632            t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
9633                .is_err()
9634        );
9635        // out sized for one direction while k says two.
9636        let mut short = vec![0.0; 2];
9637        assert!(
9638            t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
9639                .is_err()
9640        );
9641    }
9642
9643    /// A `maximize` model's objective is negated by the evaluator, and
9644    /// the HVP has to agree with `eval_h` about that — otherwise a
9645    /// Hessian-free step would climb where the sparse path descends.
9646    #[test]
9647    fn hessian_vector_product_respects_maximize_sign() {
9648        let obj = bin(BinOp::Pow, v(0), c(2.0));
9649        let mut p = parts(1, obj, Vec::new());
9650        p.minimize = false;
9651        let prob = NlProblem::from_expressions(p).expect("build");
9652        let mut t = NlTnlp::try_new(prob).expect("tnlp");
9653        t.get_nlp_info().unwrap();
9654
9655        // max x0^2 is minimized as -x0^2, so ∇² = -2.
9656        let mut out = vec![0.0; 1];
9657        t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
9658            .expect("hvp");
9659        assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
9660    }
9661}