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_tape::{HybridTape, Tape, hybrid_supported};
36use pounce_common::types::{Index, Number, lower_bound_present, upper_bound_present};
37use pounce_nlp::tnlp::{
38    BoundsInfo, IDX_NAMES, IndexStyle, IpoptCq, IpoptData, Linearity, MetaData, NlpInfo,
39    ScalingRequest, Solution, SparsityRequest, StartingPoint, TNLP,
40};
41use std::cell::RefCell;
42use std::collections::{BTreeMap, BTreeSet};
43use std::path::Path;
44use std::rc::Rc;
45use std::sync::Arc;
46
47#[derive(Debug, Clone)]
48pub enum Expr {
49    /// Numeric constant.
50    Const(Number),
51    /// Variable reference (0-based index into `x`).
52    Var(usize),
53    /// Binary op: `args = [lhs, rhs]`.
54    Binary(BinOp, Box<Expr>, Box<Expr>),
55    /// Unary op.
56    Unary(UnaryOp, Box<Expr>),
57    /// n-ary sum (opcode `o54` — variadic; we may emit it from `o0`
58    /// folding optimization, but the parser treats `o0` as binary).
59    Sum(Vec<Expr>),
60    /// Reference to a common subexpression (`.nl` `V` segment). The
61    /// payload is a shared body; many references to the same CSE share
62    /// one `Arc`, so the parsed problem is a DAG. Walking through `Cse`
63    /// is mathematically equivalent to inlining the body at each
64    /// occurrence (every reference is an independent occurrence in the
65    /// chain rule), so eval/grad/collect_vars just recurse into the
66    /// inner `Expr`. The pointer is atomically refcounted (`Arc`, not
67    /// `Rc`) so a parsed problem — and the `NlTnlp` built from it —
68    /// is `Send` and can move to a rayon worker for batched solving
69    /// (pounce#126); sharing is still read-only after parse.
70    Cse(Arc<Expr>),
71    /// AMPL imported (external) function call. `id` matches an entry in
72    /// `NlProblem.imported_funcs`; resolution to a live shared library
73    /// happens when the tape is built (see `nl_external::ExternalResolver`).
74    Funcall { id: usize, args: Vec<FuncallArg> },
75    /// Relational comparison (`o22`/`o23`/`o24`/`o28`/`o29`/`o30`).
76    /// Evaluates to `1.0` when the comparison holds, else `0.0`. The
77    /// result is piecewise-constant, so it has zero derivative
78    /// everywhere (the kink at equality is ignored — standard
79    /// subgradient-free treatment, matching ASL).
80    Compare(CmpOp, Box<Expr>, Box<Expr>),
81    /// Logical AND (`o21`). `1.0` iff both operands are nonzero.
82    /// Zero derivative (piecewise constant).
83    And(Box<Expr>, Box<Expr>),
84    /// Logical OR (`o20`). `1.0` iff either operand is nonzero.
85    /// Zero derivative (piecewise constant).
86    Or(Box<Expr>, Box<Expr>),
87    /// Logical NOT (`o34`). `1.0` iff the operand is zero.
88    /// Zero derivative (piecewise constant).
89    Not(Box<Expr>),
90    /// `if-then-else` (`o35` OPIFnl). Evaluates `cond`; when it is
91    /// nonzero the value and all derivatives flow through `then_`,
92    /// otherwise through `else_`. The branch switch is a non-smooth
93    /// event the derivative ignores (it differentiates only the
94    /// active branch), exactly as ASL/IPOPT does for `if`.
95    Cond {
96        cond: Box<Expr>,
97        then_: Box<Expr>,
98        else_: Box<Expr>,
99    },
100    /// n-ary minimum (`o11` MINLIST). Value is the smallest operand.
101    /// Piecewise linear: the derivative flows through whichever operand
102    /// is currently smallest (a subgradient; ties resolve to the first
103    /// such operand), and the second derivative is identically zero —
104    /// the standard AD treatment for min/max, matching ASL/IPOPT.
105    MinList(Vec<Expr>),
106    /// n-ary maximum (`o12` MAXLIST). Value is the largest operand;
107    /// derivative routing mirrors [`Expr::MinList`].
108    MaxList(Vec<Expr>),
109}
110
111/// Relational operator carried by [`Expr::Compare`]. The variants map
112/// 1:1 onto AMPL opcodes `o22 LT`, `o23 LE`, `o24 EQ`, `o28 GE`,
113/// `o29 GT`, `o30 NE`.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum CmpOp {
116    Lt,
117    Le,
118    Eq,
119    Ge,
120    Gt,
121    Ne,
122}
123
124/// One positional argument to an AMPL imported function call. AMPL splits
125/// arguments into reals (carried by `ra[]`) and strings (carried by `sa[]`);
126/// `FuncallArg` mirrors that split. Real args are arbitrary expressions.
127#[derive(Debug, Clone)]
128pub enum FuncallArg {
129    Real(Expr),
130    Str(String),
131}
132
133/// An AMPL imported (external) function declaration from a top-level
134/// `F<id> <type> <nargs> <name>` segment.
135#[derive(Debug, Clone)]
136pub struct ImportedFunc {
137    pub id: usize,
138    /// 0 = real-valued, 1 = string-args (per AMPL's funcadd ABI).
139    pub kind: usize,
140    /// Declared arg count. >=0 exact arity; <=-1 means at least `-(nargs+1)`.
141    pub nargs: i64,
142    pub name: String,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum BinOp {
147    Add,
148    Sub,
149    Mul,
150    Div,
151    Pow,
152    /// Two-argument arctangent `atan2(a, b)` with operands `(y, x)`.
153    Atan2,
154    /// `a·ln(a/b)` — GAMS `centropy`. No `.nl` opcode; in-memory `Expr` only.
155    ///
156    /// Fused for the same reason as [`UnaryOp::XLogX`], plus one of its own:
157    /// `∂²/∂b²` is `a/b²`, and `b²` overflows for `|b| > 1.3e154` while
158    /// `a/b²` itself stays comfortably in range. The fused rule evaluates it
159    /// as `q/b` with `q = a/b` and never squares anything.
160    CEntropy,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum UnaryOp {
165    Neg,
166    Sqrt,
167    Log,
168    Exp,
169    Abs,
170    Sin,
171    Cos,
172    Log10,
173    Tan,
174    Atan,
175    Acos,
176    Sinh,
177    Cosh,
178    Tanh,
179    Asin,
180    Acosh,
181    Asinh,
182    Atanh,
183    /// Gauss error function. No `.nl` opcode maps here — AMPL has no `erf`,
184    /// so the parser never emits it — but the in-memory builder (issue #469)
185    /// does, which is the whole point: a frontend that constructs an `Expr`
186    /// directly is not limited to what `.nl` can spell.
187    Erf,
188    /// `a·ln(a)` — GAMS `entropy`. Like [`UnaryOp::Erf`], no `.nl` opcode maps
189    /// here; it is reachable only from an in-memory `Expr`.
190    ///
191    /// Fused rather than lowered to `Mul(a, Log(a))` because the chain rule
192    /// *cannot* produce its second derivative. `(a·ln a)'' = 1/a` is finite
193    /// wherever `a > 0` — at `a = 1e-299` it is `1e299` — but every
194    /// decomposition routes through `ln''(a) = -1/a² = -1e598`, which exceeds
195    /// `f64::MAX`. A composite that is in range, built from a factor that is
196    /// not, is unreachable by any chain rule however carefully written, so the
197    /// fusion is a correctness requirement rather than an optimization.
198    XLogX,
199}
200
201/// Parsed `.nl` problem in the form needed by `NlTnlp`.
202#[derive(Debug, Clone)]
203pub struct NlProblem {
204    pub n: usize,
205    pub m: usize,
206    pub num_obj: usize,
207    pub minimize: bool,
208    pub obj_nonlinear: Expr,
209    pub obj_linear: Vec<(usize, Number)>,
210    pub obj_constant: Number,
211    /// Per-constraint nonlinear part (length m).
212    pub con_nonlinear: Vec<Expr>,
213    /// Per-constraint linear part (length m), each a list of (var, coef).
214    pub con_linear: Vec<Vec<(usize, Number)>>,
215    pub x_l: Vec<Number>,
216    pub x_u: Vec<Number>,
217    pub g_l: Vec<Number>,
218    pub g_u: Vec<Number>,
219    pub x0: Vec<Number>,
220    pub lambda0: Vec<Number>,
221    /// AMPL suffix dictionaries. Variable / constraint / objective
222    /// suffixes are stored as dense vectors (length n / m / num_obj)
223    /// with the sparse `.nl` `S`-segment entries scattered in, default
224    /// zero. The integer / real split matches the `S`-segment header's
225    /// kind bit (`0x4` ⇒ real, else integer). See
226    /// <https://ampl.com/REFS/hooking2.pdf> §6 and the upstream `.nl`
227    /// reader in `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`.
228    pub suffixes: NlSuffixes,
229    /// The model's own AMPL option words, taken verbatim from `.nl`
230    /// header line 0 (`g<count> <opt0> <opt1> ...`). A solver echoes
231    /// these back in the `.sol` `Options` block rather than interpreting
232    /// them — see [`crate::sol_writer::format_sol_with_options`]. Empty
233    /// for problems not built from a `.nl` file.
234    pub ampl_options: Vec<i64>,
235    /// AMPL imported (external) functions declared via top-level `F` segments.
236    /// Empty unless the `.nl` file calls compiled-C user functions (typically
237    /// emitted by IDAES property packages — see issue #49).
238    pub imported_funcs: Vec<ImportedFunc>,
239    /// Variable names from the sibling `.col` file, index-aligned to `x`
240    /// (one name per line, column order). Empty when no `.col` file was
241    /// found — AMPL only emits it under `option auxfiles rc;`.
242    ///
243    /// Carrying names lets diagnostics report `flow_balance` / `T_reactor`
244    /// instead of `c[3]` / `x[132]`. Lee et al. (2024) identify the gap
245    /// between detecting an issue and tracing it to a *named* equation as a
246    /// central roadblock for equation-oriented model debugging; threading
247    /// names through to the solver/debugger is the prerequisite for closing
248    /// it. See <https://doi.org/10.69997/sct.147875>.
249    pub var_names: Vec<String>,
250    /// Constraint names from the sibling `.row` file, index-aligned to `g`
251    /// (one name per line, row order). Empty when no `.row` file was found.
252    /// See [`NlProblem::var_names`] for why names are captured.
253    pub con_names: Vec<String>,
254}
255
256/// The pieces of a model built in memory, as handed to
257/// [`NlProblem::from_expressions`].
258///
259/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
260/// split to fill in, because the AD tape treats a linear term exactly like
261/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
262/// optimization, not an evaluator requirement). `n` is taken from the length
263/// of `x_l`; `m` from the length of `constraints`.
264///
265/// One cost to that simplification, in *metadata* rather than values: with
266/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
267/// when its expression is literally `Const(0.0)`, so a genuinely linear row
268/// built here reports `NonLinear`. Presolve consumes that tag, and the
269/// direction is the safe one — it loses tightening it could have done, and
270/// never asserts linearity that does not hold — but a frontend that cares
271/// about presolve strength on linear rows should know the tag is
272/// pessimistic on this path.
273#[derive(Debug, Clone)]
274pub struct NlProblemParts {
275    /// `true` to minimize `objective`, `false` to maximize it. Matches
276    /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
277    /// so callers always see the minimization form.
278    pub minimize: bool,
279    /// Objective expression.
280    pub objective: Expr,
281    /// Constant offset added to the objective.
282    pub obj_constant: Number,
283    /// One expression per constraint row; row `i` is bounded by
284    /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
285    pub constraints: Vec<Expr>,
286    /// Variable bounds and starting point, each length `n`. Use `±1e19`
287    /// for "unbounded", the same sentinel the `.nl` reader emits.
288    pub x_l: Vec<Number>,
289    pub x_u: Vec<Number>,
290    pub x0: Vec<Number>,
291    /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
292    /// equality row.
293    pub g_l: Vec<Number>,
294    pub g_u: Vec<Number>,
295    /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
296    /// consumer falls back to indices (see [`NlProblem::var_names`]).
297    pub var_names: Vec<String>,
298    pub con_names: Vec<String>,
299}
300
301impl NlProblem {
302    /// Assemble a problem from expression trees, with no `.nl` file
303    /// anywhere in the loop (issue #469).
304    ///
305    /// A modeling frontend that already has its own expression DAG should
306    /// come in here rather than serialize to `.nl` and re-parse: the round
307    /// trip is not only slower, it is *lossy*, because `.nl` writers
308    /// routinely refuse operators this tape supports natively (`atan2`,
309    /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
310    ///
311    /// The result is an ordinary [`NlProblem`], so it feeds
312    /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
313    /// does: objective, gradient, constraints, Jacobian + structure,
314    /// Lagrangian Hessian + structure, and
315    /// [`NlTnlp::hessian_vector_product`].
316    ///
317    /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
318    /// — the latter would otherwise be an out-of-bounds read in the tape's
319    /// forward sweep, so it must be caught while it is still a diagnosable
320    /// user error.
321    pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
322        let NlProblemParts {
323            minimize,
324            objective,
325            obj_constant,
326            constraints,
327            x_l,
328            x_u,
329            x0,
330            g_l,
331            g_u,
332            var_names,
333            con_names,
334        } = parts;
335
336        let n = x_l.len();
337        let m = constraints.len();
338        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
339            if got == want {
340                Ok(())
341            } else {
342                Err(format!(
343                    "from_expressions: {name} has length {got}, expected {want}"
344                ))
345            }
346        };
347        check("x_u", x_u.len(), n)?;
348        check("x0", x0.len(), n)?;
349        check("g_l", g_l.len(), m)?;
350        check("g_u", g_u.len(), m)?;
351        if !var_names.is_empty() {
352            check("var_names", var_names.len(), n)?;
353        }
354        if !con_names.is_empty() {
355            check("con_names", con_names.len(), m)?;
356        }
357
358        // Structural validation. Memoized on `Cse` pointer identity so a
359        // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
360        let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
361        validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
362        for (i, c) in constraints.iter().enumerate() {
363            validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
364        }
365
366        Ok(NlProblem {
367            n,
368            m,
369            num_obj: 1,
370            minimize,
371            obj_nonlinear: objective,
372            obj_linear: Vec::new(),
373            obj_constant,
374            con_nonlinear: constraints,
375            con_linear: vec![Vec::new(); m],
376            x_l,
377            x_u,
378            g_l,
379            g_u,
380            x0,
381            lambda0: vec![0.0; m],
382            suffixes: NlSuffixes::default(),
383            imported_funcs: Vec::new(),
384            ampl_options: Vec::new(),
385            var_names,
386            con_names,
387        })
388    }
389}
390
391/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
392/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
393///
394/// Both are things the tape cannot recover from later. An out-of-range
395/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
396/// worse-looking than it is fatal: `from_expressions` has nowhere to put
397/// the `F`-segment declarations an AMPL imported function needs
398/// (`NlProblemParts` has no field for them, and the built problem's
399/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
400/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
401/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
402/// failure to "funcall id N has no F<N> declaration". Rejecting it here
403/// says the true thing: this door does not carry external functions; go
404/// through `read_nl` / `parse_nl_text` for a model that needs them.
405///
406/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
407/// passing one set through a whole problem keeps the walk linear in
408/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
409/// repeat visit is sound because the caller aborts on the first violation:
410/// reaching a body a second time proves the first visit found none.
411fn validate_expr(
412    e: &Expr,
413    n: usize,
414    seen: &mut std::collections::HashSet<*const Expr>,
415) -> Result<(), String> {
416    match e {
417        Expr::Const(_) => Ok(()),
418        Expr::Var(i) => {
419            if *i < n {
420                Ok(())
421            } else {
422                Err(format!("references Var({i}) but n = {n}"))
423            }
424        }
425        Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
426            validate_expr(a, n, seen)?;
427            validate_expr(b, n, seen)
428        }
429        Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
430        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
431            for a in args {
432                validate_expr(a, n, seen)?;
433            }
434            Ok(())
435        }
436        Expr::Cond { cond, then_, else_ } => {
437            validate_expr(cond, n, seen)?;
438            validate_expr(then_, n, seen)?;
439            validate_expr(else_, n, seen)
440        }
441        Expr::Cse(body) => {
442            if seen.insert(Arc::as_ptr(body)) {
443                validate_expr(body, n, seen)
444            } else {
445                Ok(())
446            }
447        }
448        Expr::Funcall { id, .. } => Err(format!(
449            "references AMPL imported function id {id}, which this path cannot \
450             resolve: a problem built from expressions has no F-segment \
451             declarations to bind it to. Load such a model with read_nl or \
452             parse_nl_text instead."
453        )),
454    }
455}
456
457/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
458/// into dense vectors at problem load time so callers can index by
459/// variable / constraint number directly. Empty maps when the `.nl`
460/// file declared no suffixes.
461#[derive(Debug, Clone, Default)]
462pub struct NlSuffixes {
463    /// Variable-level integer suffixes (kind = 0). Each vector has
464    /// length `n_full` (problem variables).
465    pub var_int: BTreeMap<String, Vec<Index>>,
466    /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
467    pub con_int: BTreeMap<String, Vec<Index>>,
468    /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
469    pub obj_int: BTreeMap<String, Vec<Index>>,
470    /// Problem-level integer suffixes (kind = 3). Single value per name.
471    pub problem_int: BTreeMap<String, Index>,
472    /// Variable-level real suffixes (kind = 4). Length `n_full`.
473    pub var_real: BTreeMap<String, Vec<Number>>,
474    /// Constraint-level real suffixes (kind = 5). Length `m_full`.
475    pub con_real: BTreeMap<String, Vec<Number>>,
476    /// Objective-level real suffixes (kind = 6). Length `num_obj`.
477    pub obj_real: BTreeMap<String, Vec<Number>>,
478    /// Problem-level real suffixes (kind = 7). Single value per name.
479    pub problem_real: BTreeMap<String, Number>,
480}
481
482/// Parse an `.nl` file from disk.
483///
484/// After parsing the `.nl` body, this also looks for AMPL's optional
485/// sibling name files — `stub.col` (variable names) and `stub.row`
486/// (constraint names), emitted only when the modeler sets
487/// `option auxfiles rc;`. When present and well-formed they populate
488/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
489/// malformed the names stay empty and every downstream consumer falls
490/// back to indices. Names are a diagnostic nicety, never load-blocking
491/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
492pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
493    // AMPL invokes a solver with an extensionless *stub* — e.g.
494    // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
495    // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
496    // path as given is missing but appending `.nl` names an existing file,
497    // resolve to that. This only ever *adds* a fallback: an existing path
498    // is read verbatim, so nothing changes for callers that already pass a
499    // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
500    let resolved = if path.exists() {
501        path.to_path_buf()
502    } else {
503        let with_nl = append_extension(path, "nl");
504        if with_nl.exists() {
505            with_nl
506        } else {
507            path.to_path_buf()
508        }
509    };
510    let txt = std::fs::read_to_string(&resolved)
511        .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
512    let mut prob = parse_nl_text(&txt)?;
513    prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
514    prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
515    Ok(prob)
516}
517
518/// Append `.ext` to `path`'s full file name (AMPL stub convention:
519/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
520/// which would *replace* an existing extension. A stub that itself
521/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
522/// way AMPL names it.
523fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
524    let mut name = path.as_os_str().to_os_string();
525    name.push(".");
526    name.push(ext);
527    std::path::PathBuf::from(name)
528}
529
530/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
531/// order. Returns the first `expected` names, or an empty vector when the
532/// file is missing, unreadable, or has fewer than `expected` lines.
533///
534/// Returning empty (rather than erroring) on any mismatch is deliberate:
535/// names are an optional diagnostic aid, so a missing or truncated file
536/// must never block a solve. The `.take(expected)` also drops AMPL's
537/// convention of appending the objective name after the constraint names
538/// in `.row`, keeping the result aligned 1:1 with `g`.
539fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
540    let Ok(txt) = std::fs::read_to_string(path) else {
541        return Vec::new();
542    };
543    let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
544    if names.len() == expected {
545        names
546    } else {
547        Vec::new()
548    }
549}
550
551/// The value of a constraint's nonlinear part when that part is a
552/// constant, else `None`. Drives the constant-row-body fold in
553/// [`parse_nl_text`].
554///
555/// "Constant" is decided by *evaluation*, not by syntax: a literal
556/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
557/// and is folded too.
558///
559/// Declines in two cases, both of which would make the fold unsound:
560/// * The expression calls an AMPL imported function. Its value depends on
561///   a shared library resolved much later (`nl_external::ExternalResolver`),
562///   so it is not a parse-time constant even with constant arguments — and
563///   [`eval_expr`] panics on `Funcall` rather than guess.
564/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
565///   a NaN or infinity into a bound would corrupt a row that is merely
566///   infeasible; leaving the expression in place keeps it a solver-time
567///   fact.
568fn row_constant_value(e: &Expr) -> Option<Number> {
569    // The identity zero the parser preallocates for every untouched row is
570    // by far the most common input; settle it without walking anything.
571    if let Expr::Const(c) = e {
572        return c.is_finite().then_some(*c);
573    }
574    let mut vars: BTreeSet<usize> = BTreeSet::new();
575    collect_vars(e, &mut vars);
576    if !vars.is_empty() {
577        return None;
578    }
579    let mut funcs: BTreeSet<usize> = BTreeSet::new();
580    crate::nl_external::collect_funcall_ids(e, &mut funcs);
581    if !funcs.is_empty() {
582        return None;
583    }
584    // Variable-free, so no `Expr::Var` can index into the (empty) point.
585    let v = eval_expr(e, &[]);
586    v.is_finite().then_some(v)
587}
588
589/// Parse `.nl` text content. Public so tests can use string literals.
590pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
591    let mut p = Parser::new(txt);
592    p.parse_header()?;
593    let n = p.n;
594    let m = p.m;
595    let num_obj = p.num_obj;
596
597    let mut con_nonlinear: Vec<Expr> = (0..m).map(|_| Expr::Const(0.0)).collect();
598    let mut obj_nonlinear = Expr::Const(0.0);
599    let mut minimize = true;
600    let mut obj_linear: Vec<(usize, Number)> = Vec::new();
601    let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
602    let mut x_l = vec![-1e19; n];
603    let mut x_u = vec![1e19; n];
604    let mut g_l = vec![-1e19; m];
605    let mut g_u = vec![1e19; m];
606    let mut x0 = vec![0.0; n];
607    let mut lambda0 = vec![0.0; m];
608    let mut suffixes = NlSuffixes::default();
609    let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
610
611    while let Some(line) = p.peek_segment_line() {
612        let tag = line
613            .trim_start()
614            .chars()
615            .next()
616            .ok_or("unexpected blank segment header")?;
617        match tag {
618            'C' => {
619                let (_hdr, rest) = p.eat_segment_header()?;
620                let _ = rest;
621                let idx = parse_segment_index(_hdr, 'C')?;
622                if idx >= m {
623                    return Err(format!("C{idx} out of range; m={m}"));
624                }
625                con_nonlinear[idx] = p.parse_expr()?;
626            }
627            'O' => {
628                let (hdr, _rest) = p.eat_segment_header()?;
629                let parts: Vec<&str> = hdr.split_whitespace().collect();
630                if parts.len() < 2 {
631                    return Err(format!("malformed O-segment header: {hdr}"));
632                }
633                let idx = parse_segment_index(parts[0], 'O')?;
634                let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
635                if idx == 0 {
636                    minimize = kind == 0;
637                    obj_nonlinear = p.parse_expr()?;
638                } else {
639                    // Extra objectives are read but ignored.
640                    let _ = p.parse_expr()?;
641                }
642            }
643            'r' => {
644                p.eat_segment_header()?;
645                for i in 0..m {
646                    let line = p.next_data_line()?;
647                    let (lo, hi) = parse_bound_line(line)?;
648                    g_l[i] = lo;
649                    g_u[i] = hi;
650                }
651            }
652            'b' => {
653                p.eat_segment_header()?;
654                for i in 0..n {
655                    let line = p.next_data_line()?;
656                    let (lo, hi) = parse_bound_line(line)?;
657                    x_l[i] = lo;
658                    x_u[i] = hi;
659                }
660            }
661            'k' => {
662                // Column counts in the Jacobian; we don't need their
663                // values for evaluation (the J segments give explicit
664                // lists), but we must consume exactly as many data lines
665                // as follow or the segment stream desyncs. The `.nl`
666                // format writes that line count in the header itself
667                // (`k<count>`), and the standard value is `n-1`. Read the
668                // declared count rather than assuming it: a file with a
669                // nonstandard count would otherwise leave us reading the
670                // wrong number of lines, swallowing a later segment header
671                // (or stopping short) and failing with a confusing,
672                // far-removed error. Validate against the expected `n-1`
673                // so a mismatch surfaces here, clearly, at its source.
674                let (hdr, _) = p.eat_segment_header()?;
675                let declared = parse_segment_index(hdr, 'k')?;
676                let expected = if n == 0 { 0 } else { n - 1 };
677                if declared != expected {
678                    return Err(format!(
679                        "k-segment declares {declared} column-count lines but \
680                         the standard count for n={n} variables is {expected}"
681                    ));
682                }
683                for _ in 0..declared {
684                    p.next_data_line()?;
685                }
686            }
687            'J' => {
688                let (hdr, _) = p.eat_segment_header()?;
689                let parts: Vec<&str> = hdr.split_whitespace().collect();
690                if parts.len() < 2 {
691                    return Err(format!("malformed J-segment header: {hdr}"));
692                }
693                let row = parse_segment_index(parts[0], 'J')?;
694                let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
695                if row >= m {
696                    return Err(format!("J{row} out of range"));
697                }
698                for _ in 0..nz {
699                    let line = p.next_data_line()?;
700                    let (var, coef) = parse_var_coef(line)?;
701                    // Validate the column index here: an out-of-range `var`
702                    // would otherwise be stored and panic as a slice OOB
703                    // (`x[var]`) during constraint evaluation. Mirror the
704                    // clean parse error used for the row index above.
705                    if var >= n {
706                        return Err(format!(
707                            "J{row} entry variable index {var} out of range (n={n})"
708                        ));
709                    }
710                    con_linear[row].push((var, coef));
711                }
712            }
713            'G' => {
714                let (hdr, _) = p.eat_segment_header()?;
715                let parts: Vec<&str> = hdr.split_whitespace().collect();
716                if parts.len() < 2 {
717                    return Err(format!("malformed G-segment header: {hdr}"));
718                }
719                let idx = parse_segment_index(parts[0], 'G')?;
720                let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
721                let mut acc = Vec::with_capacity(nz);
722                for _ in 0..nz {
723                    let line = p.next_data_line()?;
724                    let (var, coef) = parse_var_coef(line)?;
725                    // Same as J: reject an out-of-range gradient column index
726                    // up front rather than letting it panic on `x[var]` later.
727                    if var >= n {
728                        return Err(format!(
729                            "G{idx} entry variable index {var} out of range (n={n})"
730                        ));
731                    }
732                    acc.push((var, coef));
733                }
734                if idx == 0 {
735                    obj_linear = acc;
736                }
737            }
738            'x' => {
739                let (hdr, _) = p.eat_segment_header()?;
740                let parts: Vec<&str> = hdr.split_whitespace().collect();
741                let nx: usize = parts
742                    .first()
743                    .and_then(|s| s.trim_start_matches('x').parse().ok())
744                    .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
745                for _ in 0..nx {
746                    let line = p.next_data_line()?;
747                    let (idx, val) = parse_var_coef(line)?;
748                    // Reject out-of-range indices as a parse error, matching
749                    // J/G strictness, rather than silently dropping the entry
750                    // (which hides a corrupt initial-primal segment).
751                    if idx >= n {
752                        return Err(format!(
753                            "x-segment variable index {idx} out of range (n={n})"
754                        ));
755                    }
756                    x0[idx] = val;
757                }
758            }
759            'd' => {
760                let (hdr, _) = p.eat_segment_header()?;
761                let parts: Vec<&str> = hdr.split_whitespace().collect();
762                let nd: usize = parts
763                    .first()
764                    .and_then(|s| s.trim_start_matches('d').parse().ok())
765                    .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
766                for _ in 0..nd {
767                    let line = p.next_data_line()?;
768                    let (idx, val) = parse_var_coef(line)?;
769                    // Reject out-of-range indices as a parse error, matching
770                    // J/G strictness, rather than silently dropping the entry
771                    // (which hides a corrupt initial-dual segment).
772                    if idx >= m {
773                        return Err(format!(
774                            "d-segment constraint index {idx} out of range (m={m})"
775                        ));
776                    }
777                    lambda0[idx] = val;
778                }
779            }
780            'V' => p.parse_v_segment()?,
781            'S' => {
782                parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
783            }
784            'F' => {
785                // AMPL imported (external) function declaration:
786                // `F<k> <type> <nargs> <name>`.
787                let (hdr, _rest) = p.eat_segment_header()?;
788                let parts: Vec<&str> = hdr.split_whitespace().collect();
789                if parts.is_empty() {
790                    return Err(format!("malformed F-segment header: '{hdr}'"));
791                }
792                let id = parse_segment_index(parts[0], 'F')?;
793                let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
794                let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
795                let name = parts.get(3).copied().unwrap_or("").to_string();
796                imported_funcs.push(ImportedFunc {
797                    id,
798                    kind,
799                    nargs,
800                    name,
801                });
802            }
803            other => return Err(format!("unknown .nl segment tag '{other}'")),
804        }
805    }
806
807    // Normalize constant row bodies into the row bounds, so that
808    // "the nonlinear part is the identity zero" and "this row's body is
809    // Σaⱼxⱼ" mean the same thing for every downstream consumer.
810    //
811    // A `C<i>` segment holding a variable-free expression (`n3.0`, or
812    // anything that evaluates to a constant such as `o0 n1 n2`) is an
813    // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
814    // which every consumer reads as "nonlinear": the linearity predicate
815    // (`get_constraints_linearity`), which is what makes presolve's
816    // linear-equality reduction decline the row; the CLI problem
817    // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
818    // polynomial walk absorbs a bare literal but not a constant it has to
819    // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
820    // NLP and never reached the convex path); and the FBBT translator.
821    // Folding here fixes all of them at once, with no per-consumer audit.
822    //
823    // The shift is exact and invisible from outside: the body drops by `c`
824    // and each bound drops by `c` with it, so the feasible set, the active
825    // set, and the duals are unchanged (`gh #492`). This is the same
826    // normalization `qp_extract::analyze_quadratic_full` already performs
827    // ad hoc via `const_shift`, promoted to the parse boundary.
828    for i in 0..m {
829        let Some(c) = row_constant_value(&con_nonlinear[i]) else {
830            continue;
831        };
832        // Presence is directional (gh #401): shifting an *absent* bound
833        // would turn the ±1e19 sentinel into a real bound for `c < 0`
834        // (lower) or `c > 0` (upper), inventing a constraint. Leave the
835        // sentinels alone.
836        if lower_bound_present(g_l[i]) {
837            g_l[i] -= c;
838        }
839        if upper_bound_present(g_u[i]) {
840            g_u[i] -= c;
841        }
842        con_nonlinear[i] = Expr::Const(0.0);
843    }
844
845    Ok(NlProblem {
846        n,
847        m,
848        num_obj,
849        minimize,
850        obj_nonlinear,
851        obj_linear,
852        obj_constant: 0.0,
853        con_nonlinear,
854        con_linear,
855        x_l,
856        x_u,
857        g_l,
858        g_u,
859        x0,
860        lambda0,
861        suffixes,
862        ampl_options: p.ampl_options.clone(),
863        imported_funcs,
864        // `.nl` text carries no names; `read_nl_file` fills these from the
865        // sibling `.col`/`.row` files when present.
866        var_names: Vec::new(),
867        con_names: Vec::new(),
868    })
869}
870
871/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
872/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
873///
874/// ```text
875/// S<kind> <nentries> <suffix_name>
876/// <idx> <value>      ... nentries lines
877/// ```
878///
879/// `<kind>` is a 3-bit encoding:
880/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
881///   2 = objectives, 3 = problem-level.
882/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
883///
884/// Sparse entries scatter into a freshly-allocated dense vector (zero
885/// default), sized for the target dimension. Problem-level suffixes
886/// (kind = 3 / 7) carry a single value.
887fn parse_suffix_segment(
888    p: &mut Parser,
889    n: usize,
890    m: usize,
891    num_obj: usize,
892    out: &mut NlSuffixes,
893) -> Result<(), String> {
894    let (hdr, _) = p.eat_segment_header()?;
895    let parts: Vec<&str> = hdr.split_whitespace().collect();
896    if parts.len() < 3 {
897        return Err(format!(
898            "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
899        ));
900    }
901    let kind_str = parts[0].trim_start_matches('S');
902    let kind: u32 = kind_str
903        .parse()
904        .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
905    let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
906    let name = parts[2].to_string();
907
908    let is_real = (kind & 0x4) != 0;
909    let target = kind & 0x3;
910    let target_dim = match target {
911        0 => n,
912        1 => m,
913        2 => num_obj,
914        3 => 0, // problem-level — entries are single-valued (idx=0)
915        _ => unreachable!("kind & 0x3 is in 0..=3"),
916    };
917
918    // Pre-allocate dense buffers (default zero). Problem-level kinds
919    // (3 / 7) hold a single scalar — we still read the (idx, value)
920    // pairs but only the value field is meaningful.
921    let mut int_buf: Vec<Index> = if !is_real && target != 3 {
922        vec![0; target_dim]
923    } else {
924        Vec::new()
925    };
926    let mut real_buf: Vec<Number> = if is_real && target != 3 {
927        vec![0.0; target_dim]
928    } else {
929        Vec::new()
930    };
931    let mut problem_int: Index = 0;
932    let mut problem_real: Number = 0.0;
933
934    for _ in 0..nentries {
935        let line = p.next_data_line()?;
936        let parts: Vec<&str> = line.split_whitespace().collect();
937        if parts.len() < 2 {
938            return Err(format!(
939                "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
940            ));
941        }
942        let idx: usize = parts[0]
943            .parse()
944            .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
945        if target != 3 && idx >= target_dim {
946            return Err(format!(
947                "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
948            ));
949        }
950        if is_real {
951            let v: Number = parts[1]
952                .parse()
953                .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
954            if target == 3 {
955                problem_real = v;
956            } else {
957                real_buf[idx] = v;
958            }
959        } else {
960            let v: Index = parts[1]
961                .parse()
962                .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
963            if target == 3 {
964                problem_int = v;
965            } else {
966                int_buf[idx] = v;
967            }
968        }
969    }
970
971    match (target, is_real) {
972        (0, false) => {
973            out.var_int.insert(name, int_buf);
974        }
975        (1, false) => {
976            out.con_int.insert(name, int_buf);
977        }
978        (2, false) => {
979            out.obj_int.insert(name, int_buf);
980        }
981        (3, false) => {
982            out.problem_int.insert(name, problem_int);
983        }
984        (0, true) => {
985            out.var_real.insert(name, real_buf);
986        }
987        (1, true) => {
988            out.con_real.insert(name, real_buf);
989        }
990        (2, true) => {
991            out.obj_real.insert(name, real_buf);
992        }
993        (3, true) => {
994            out.problem_real.insert(name, problem_real);
995        }
996        _ => unreachable!(),
997    }
998    Ok(())
999}
1000
1001fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1002    let trimmed = s.trim_start_matches(tag);
1003    trimmed
1004        .parse()
1005        .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1006}
1007
1008// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1009// `G` / `x` / `d` data line, so between them they see every Jacobian and
1010// gradient nonzero in the file. Both walk the whitespace iterator
1011// directly instead of collecting a `Vec<&str>` first — that collect was
1012// a heap allocation per line on top of the one the reader used to make
1013// handing the line over.
1014fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1015    let mut parts = line.split_whitespace();
1016    let kind: i32 = parts
1017        .next()
1018        .ok_or("empty bound line")?
1019        .parse()
1020        .map_err(|e| format!("bound kind: {e}"))?;
1021    let lo;
1022    let hi;
1023    match kind {
1024        0 => {
1025            // 0  lo  hi
1026            let (l, h) = (parts.next(), parts.next());
1027            let (Some(l), Some(h)) = (l, h) else {
1028                return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1029            };
1030            lo = l.parse().map_err(|e| format!("lo: {e}"))?;
1031            hi = h.parse().map_err(|e| format!("hi: {e}"))?;
1032        }
1033        1 => {
1034            // 1  hi
1035            let Some(h) = parts.next() else {
1036                return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1037            };
1038            lo = -1e19;
1039            hi = h.parse().map_err(|e| format!("hi: {e}"))?;
1040        }
1041        2 => {
1042            // 2  lo
1043            let Some(l) = parts.next() else {
1044                return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1045            };
1046            lo = l.parse().map_err(|e| format!("lo: {e}"))?;
1047            hi = 1e19;
1048        }
1049        3 => {
1050            // 3  (free)
1051            lo = -1e19;
1052            hi = 1e19;
1053        }
1054        4 => {
1055            // 4  eq
1056            let Some(v) = parts.next() else {
1057                return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1058            };
1059            let v: Number = v.parse().map_err(|e| format!("eq: {e}"))?;
1060            lo = v;
1061            hi = v;
1062        }
1063        5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1064        other => return Err(format!("unknown bound kind {other}")),
1065    }
1066    Ok((lo, hi))
1067}
1068
1069fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1070    let mut parts = line.split_whitespace();
1071    let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1072        return Err(format!("malformed var/coef line: '{line}'"));
1073    };
1074    let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1075    let c: Number = c.parse().map_err(|e| format!("coef: {e}"))?;
1076    Ok((v, c))
1077}
1078
1079struct Parser<'a> {
1080    lines: Vec<&'a str>,
1081    pos: usize,
1082    n: usize,
1083    m: usize,
1084    num_obj: usize,
1085    /// Number of AMPL imported (external) functions declared in the header.
1086    n_funcs: usize,
1087    ampl_options: Vec<i64>,
1088    /// Common subexpressions (`V` segments). Index in this vec is the
1089    /// CSE-local index, i.e. the global `.nl` index minus `n`.
1090    cses: Vec<Arc<Expr>>,
1091}
1092
1093impl<'a> Parser<'a> {
1094    fn new(txt: &'a str) -> Self {
1095        let lines: Vec<&str> = txt.lines().collect();
1096        Self {
1097            lines,
1098            pos: 0,
1099            n: 0,
1100            m: 0,
1101            num_obj: 0,
1102            n_funcs: 0,
1103            ampl_options: Vec::new(),
1104            cses: Vec::new(),
1105        }
1106    }
1107
1108    fn next_line(&mut self) -> Option<&'a str> {
1109        while self.pos < self.lines.len() {
1110            let l = self.lines[self.pos];
1111            self.pos += 1;
1112            // Strip comment after '#' for header / data lines (but
1113            // leave the segment-tag tokens untouched — they are the
1114            // first token on the line).
1115            let trimmed = strip_comment(l).trim();
1116            if !trimmed.is_empty() {
1117                return Some(l);
1118            }
1119        }
1120        None
1121    }
1122
1123    /// Next non-blank line, comment stripped and trimmed.
1124    ///
1125    /// Borrows from the source text rather than allocating: the result
1126    /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
1127    /// outlives the `&mut self` this took. A large `.nl` is mostly data
1128    /// lines — 620k of them for a 20k-variable model — and returning an
1129    /// owned `String` put one heap allocation on every single one.
1130    fn next_data_line(&mut self) -> Result<&'a str, String> {
1131        while self.pos < self.lines.len() {
1132            let l = self.lines[self.pos];
1133            self.pos += 1;
1134            let trimmed = strip_comment(l).trim();
1135            if !trimmed.is_empty() {
1136                return Ok(trimmed);
1137            }
1138        }
1139        Err("unexpected end of file in data line".to_string())
1140    }
1141
1142    fn parse_header(&mut self) -> Result<(), String> {
1143        let line0 = self.next_line().ok_or("empty .nl file")?;
1144        let trimmed = strip_comment(line0).trim();
1145        let first = trimmed.chars().next().ok_or("empty header line")?;
1146        if first != 'g' {
1147            return Err(format!(
1148                "only ASCII (g-) .nl files supported; got header '{trimmed}'"
1149            ));
1150        }
1151        // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
1152        // `g` say how many AMPL option words follow on the same line.
1153        // A solver echoes them back in the `.sol` `Options` block, so
1154        // keep them verbatim. Malformed or truncated option lists are not
1155        // fatal — the writer falls back to a generic block.
1156        let mut words = trimmed.split_whitespace();
1157        let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
1158        let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
1159        if opts.len() >= n_opts {
1160            self.ampl_options = opts[..n_opts].to_vec();
1161        }
1162
1163        // Header line 2: n_vars n_cons n_objs ranges eqns
1164        let l2 = self.next_data_line()?;
1165        let nums: Vec<&str> = l2.split_whitespace().collect();
1166        if nums.len() < 3 {
1167            return Err(format!("malformed line 2: '{l2}'"));
1168        }
1169        self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
1170        self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
1171        self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
1172
1173        // Lines 3..5 are metadata we skip.
1174        for _ in 0..3 {
1175            self.next_data_line()?;
1176        }
1177        // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
1178        let l5 = self.next_data_line()?;
1179        let nums5: Vec<&str> = l5.split_whitespace().collect();
1180        self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1181        // Lines 6..10 are metadata we don't need — skip 4 more lines.
1182        for _ in 0..4 {
1183            self.next_data_line()?;
1184        }
1185        Ok(())
1186    }
1187
1188    fn peek_segment_line(&mut self) -> Option<&'a str> {
1189        let saved = self.pos;
1190        let l = self.next_line()?;
1191        self.pos = saved;
1192        Some(l)
1193    }
1194
1195    /// Eat the next non-blank line as a segment header. Returns the
1196    /// whole header (after stripping comments) and the comment text.
1197    fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
1198        let raw = self
1199            .next_line()
1200            .ok_or_else(|| "expected segment header".to_string())?;
1201        let (hdr, comment) = split_comment(raw);
1202        Ok((hdr.trim(), comment.trim()))
1203    }
1204
1205    fn parse_expr(&mut self) -> Result<Expr, String> {
1206        let raw = self
1207            .next_line()
1208            .ok_or_else(|| "expected expression token".to_string())?;
1209        // Borrowed, not owned: this runs once per node of every
1210        // expression tree in the file, so an owned `String` here is one
1211        // heap allocation per tape op in the whole model.
1212        let tok = strip_comment(raw).trim();
1213        if tok.is_empty() {
1214            return Err("empty expression token".into());
1215        }
1216        let first = tok.chars().next().ok_or("empty expression token")?;
1217        match first {
1218            'n' => {
1219                let v: Number = tok[1..]
1220                    .trim()
1221                    .parse()
1222                    .map_err(|e| format!("n value: {e}"))?;
1223                Ok(Expr::Const(v))
1224            }
1225            'v' => {
1226                let i: usize = tok[1..]
1227                    .trim()
1228                    .parse()
1229                    .map_err(|e| format!("v index: {e}"))?;
1230                Ok(self.var_or_cse(i)?)
1231            }
1232            'o' => {
1233                let code: i32 = tok[1..]
1234                    .trim()
1235                    .parse()
1236                    .map_err(|e| format!("opcode: {e}"))?;
1237                self.parse_opcode(code)
1238            }
1239            'f' => {
1240                // AMPL imported (external) function call: `f<id> <nargs>`
1241                // followed by nargs child expressions (or string literals).
1242                let rest = &tok[1..];
1243                let mut parts = rest.split_whitespace();
1244                let id_str = parts
1245                    .next()
1246                    .ok_or_else(|| format!("missing function id in '{tok}'"))?;
1247                let nargs_str = parts
1248                    .next()
1249                    .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
1250                let id: usize = id_str
1251                    .parse()
1252                    .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
1253                let nargs: usize = nargs_str
1254                    .parse()
1255                    .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
1256                let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
1257                for _ in 0..nargs {
1258                    args.push(self.parse_funcall_arg()?);
1259                }
1260                Ok(Expr::Funcall { id, args })
1261            }
1262            't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
1263            other => Err(format!(
1264                "unexpected expression token start '{other}': '{tok}'"
1265            )),
1266        }
1267    }
1268
1269    /// Parse one argument to an AMPL imported function. An argument
1270    /// is either a normal expression (real-valued) or a string literal
1271    /// in the form `h<len>:<chars>`. AMPL emits string args only when the
1272    /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
1273    /// or a parameters-directory path for IDAES Helmholtz functions).
1274    fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
1275        // Peek the next non-blank line so we can route `h...` differently.
1276        let saved = self.pos;
1277        let raw = self
1278            .next_line()
1279            .ok_or_else(|| "expected funcall argument".to_string())?;
1280        // A string arg is a Hollerith literal `h<len>:<chars>` where the
1281        // chars are *exactly* `<len>` bytes and may legitimately contain
1282        // '#'. We must NOT strip a trailing comment before extracting the
1283        // content (that would truncate e.g. a path like `a#b`), and we
1284        // honor the declared length rather than splitting loosely on ':'.
1285        // Detect the form from the leading non-blank char of the raw line;
1286        // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
1287        let lead = raw.trim_start();
1288        if let Some(after_h) = lead.strip_prefix('h') {
1289            let colon = after_h
1290                .find(':')
1291                .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
1292            let len: usize = after_h[..colon]
1293                .trim()
1294                .parse()
1295                .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
1296            let chars = &after_h[colon + 1..];
1297            if chars.len() < len {
1298                return Err(format!(
1299                    "Hollerith string shorter than declared length {len}: {chars:?}"
1300                ));
1301            }
1302            // Take exactly `len` bytes; anything past it (trailing
1303            // whitespace, a real comment) is not part of the string.
1304            if !chars.is_char_boundary(len) {
1305                return Err(format!(
1306                    "Hollerith length {len} splits a multibyte char in {chars:?}"
1307                ));
1308            }
1309            Ok(FuncallArg::Str(chars[..len].to_string()))
1310        } else {
1311            // Rewind: parse_expr re-consumes the line we just peeked.
1312            self.pos = saved;
1313            Ok(FuncallArg::Real(self.parse_expr()?))
1314        }
1315    }
1316
1317    fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
1318        match code {
1319            0 => {
1320                let a = self.parse_expr()?;
1321                let b = self.parse_expr()?;
1322                Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
1323            }
1324            1 => {
1325                let a = self.parse_expr()?;
1326                let b = self.parse_expr()?;
1327                Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
1328            }
1329            2 => {
1330                let a = self.parse_expr()?;
1331                let b = self.parse_expr()?;
1332                Ok(Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b)))
1333            }
1334            3 => {
1335                let a = self.parse_expr()?;
1336                let b = self.parse_expr()?;
1337                Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
1338            }
1339            5 => {
1340                let a = self.parse_expr()?;
1341                let b = self.parse_expr()?;
1342                Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
1343            }
1344            15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
1345            16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
1346            39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
1347            41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
1348            42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
1349            43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
1350            44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
1351            46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
1352            38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
1353            49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
1354            53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
1355            40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
1356            45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
1357            37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
1358            51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
1359            52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
1360            50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
1361            47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
1362            // atan2(y, x): binary, operand order `y` then `x`.
1363            48 => {
1364                let a = self.parse_expr()?;
1365                let b = self.parse_expr()?;
1366                Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
1367            }
1368            // Relational comparisons (binary). Operand order is
1369            // `left OP right`.
1370            22 => self.parse_compare(CmpOp::Lt),
1371            23 => self.parse_compare(CmpOp::Le),
1372            24 => self.parse_compare(CmpOp::Eq),
1373            28 => self.parse_compare(CmpOp::Ge),
1374            29 => self.parse_compare(CmpOp::Gt),
1375            30 => self.parse_compare(CmpOp::Ne),
1376            // Logical connectives.
1377            20 => {
1378                let a = self.parse_expr()?;
1379                let b = self.parse_expr()?;
1380                Ok(Expr::Or(Box::new(a), Box::new(b)))
1381            }
1382            21 => {
1383                let a = self.parse_expr()?;
1384                let b = self.parse_expr()?;
1385                Ok(Expr::And(Box::new(a), Box::new(b)))
1386            }
1387            34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
1388            // if-then-else: condition, then-value, else-value.
1389            35 => {
1390                let cond = self.parse_expr()?;
1391                let then_ = self.parse_expr()?;
1392                let else_ = self.parse_expr()?;
1393                Ok(Expr::Cond {
1394                    cond: Box::new(cond),
1395                    then_: Box::new(then_),
1396                    else_: Box::new(else_),
1397                })
1398            }
1399            54 => {
1400                // Variadic sum: next data line gives the count.
1401                let count_line = self.next_data_line()?;
1402                let count: usize = count_line
1403                    .split_whitespace()
1404                    .next()
1405                    .ok_or_else(|| "missing variadic count".to_string())?
1406                    .parse()
1407                    .map_err(|e| format!("variadic count: {e}"))?;
1408                let mut args = Vec::with_capacity(count);
1409                for _ in 0..count {
1410                    args.push(self.parse_expr()?);
1411                }
1412                Ok(Expr::Sum(args))
1413            }
1414            // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
1415            // a count data line followed by that many operands.
1416            11 | 12 => {
1417                let count_line = self.next_data_line()?;
1418                let count: usize = count_line
1419                    .split_whitespace()
1420                    .next()
1421                    .ok_or_else(|| "missing min/max list count".to_string())?
1422                    .parse()
1423                    .map_err(|e| format!("min/max list count: {e}"))?;
1424                let mut args = Vec::with_capacity(count);
1425                for _ in 0..count {
1426                    args.push(self.parse_expr()?);
1427                }
1428                if code == 11 {
1429                    Ok(Expr::MinList(args))
1430                } else {
1431                    Ok(Expr::MaxList(args))
1432                }
1433            }
1434            // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
1435            // emits these in place of the general `o5` (OPPOW) as a hint that
1436            // one operand is constant. The distinction exists because an
1437            // integer / half-integer constant power is evaluated by a
1438            // mul/sqrt chain that stays real for a negative base, whereas the
1439            // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
1440            // they read exactly like `o5`, so they lower to the same `Pow` AST
1441            // and reuse the existing constant-power tape lowering (see
1442            // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
1443            // against the ASL reader and the `ampl/mp` opcode table:
1444            // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
1445            // unary with an implicit exponent of 2.
1446            //
1447            // o81 OP1POW: `base ^ (const exponent)` — binary, operands
1448            // `base` then `exp` (the exponent is a numeric node here).
1449            81 => {
1450                let base = self.parse_expr()?;
1451                let exp = self.parse_expr()?;
1452                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
1453            }
1454            // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
1455            82 => {
1456                let base = self.parse_expr()?;
1457                Ok(Expr::Binary(
1458                    BinOp::Pow,
1459                    Box::new(base),
1460                    Box::new(Expr::Const(2.0)),
1461                ))
1462            }
1463            // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
1464            // (the numeric node) then `exp`.
1465            83 => {
1466                let base = self.parse_expr()?;
1467                let exp = self.parse_expr()?;
1468                Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
1469            }
1470            other => Err(format!("unsupported opcode o{other}")),
1471        }
1472    }
1473
1474    /// Parse the two operands of a relational opcode into an
1475    /// [`Expr::Compare`]. Operand order is `left OP right`.
1476    fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
1477        let a = self.parse_expr()?;
1478        let b = self.parse_expr()?;
1479        Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
1480    }
1481
1482    /// Resolve a `v<i>` token into either a plain variable reference
1483    /// (`i < n`) or a shared CSE reference (`i >= n`).
1484    fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
1485        if i < self.n {
1486            Ok(Expr::Var(i))
1487        } else {
1488            let local = i - self.n;
1489            self.cses
1490                .get(local)
1491                .map(|rc| Expr::Cse(rc.clone()))
1492                .ok_or_else(|| {
1493                    format!(
1494                        "v{i} references CSE {local} but only {} have been defined",
1495                        self.cses.len()
1496                    )
1497                })
1498        }
1499    }
1500
1501    /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
1502    /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
1503    /// CSEs are numbered starting at `n` and must appear in order.
1504    fn parse_v_segment(&mut self) -> Result<(), String> {
1505        let (hdr, _) = self.eat_segment_header()?;
1506        let parts: Vec<&str> = hdr.split_whitespace().collect();
1507        if parts.len() < 2 {
1508            return Err(format!("malformed V-segment header: {hdr}"));
1509        }
1510        let cse_idx = parse_segment_index(parts[0], 'V')?;
1511        let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
1512        // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
1513        let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
1514        for _ in 0..nlin {
1515            let line = self.next_data_line()?;
1516            let (var, coef) = parse_var_coef(line)?;
1517            linear.push((var, coef));
1518        }
1519        let nonlin = self.parse_expr()?;
1520        // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
1521        // reference earlier CSEs as well as plain variables.
1522        let mut combined = nonlin;
1523        for (var, coef) in linear {
1524            let v_expr = self.var_or_cse(var)?;
1525            let term = if coef == 1.0 {
1526                v_expr
1527            } else {
1528                Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
1529            };
1530            combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
1531        }
1532        if cse_idx < self.n {
1533            return Err(format!("V{cse_idx} below n={}", self.n));
1534        }
1535        let local = cse_idx - self.n;
1536        if local != self.cses.len() {
1537            return Err(format!(
1538                "V-segment index V{cse_idx} out of order; expected V{}",
1539                self.n + self.cses.len()
1540            ));
1541        }
1542        self.cses.push(Arc::new(combined));
1543        Ok(())
1544    }
1545}
1546
1547fn strip_comment(s: &str) -> &str {
1548    match s.find('#') {
1549        Some(i) => &s[..i],
1550        None => s,
1551    }
1552}
1553
1554fn split_comment(s: &str) -> (&str, &str) {
1555    match s.find('#') {
1556        Some(i) => (&s[..i], &s[i + 1..]),
1557        None => (s, ""),
1558    }
1559}
1560
1561// --------------------------------------------------------------------
1562// Expression evaluation and gradient (tree walkers, kept for tests).
1563// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
1564// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
1565// --------------------------------------------------------------------
1566
1567/// Forward-mode value evaluation.
1568pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
1569    match e {
1570        Expr::Const(c) => *c,
1571        Expr::Var(i) => x[*i],
1572        Expr::Binary(op, a, b) => {
1573            let va = eval_expr(a, x);
1574            let vb = eval_expr(b, x);
1575            match op {
1576                BinOp::Add => va + vb,
1577                BinOp::Sub => va - vb,
1578                BinOp::Mul => va * vb,
1579                BinOp::Div => va / vb,
1580                BinOp::Pow => va.powf(vb),
1581                BinOp::Atan2 => va.atan2(vb),
1582                BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
1583            }
1584        }
1585        Expr::Unary(op, a) => {
1586            let va = eval_expr(a, x);
1587            match op {
1588                UnaryOp::Neg => -va,
1589                UnaryOp::Sqrt => va.sqrt(),
1590                UnaryOp::Log => va.ln(),
1591                UnaryOp::Log10 => va.log10(),
1592                UnaryOp::Exp => va.exp(),
1593                UnaryOp::Abs => va.abs(),
1594                UnaryOp::Sin => va.sin(),
1595                UnaryOp::Cos => va.cos(),
1596                UnaryOp::Tan => va.tan(),
1597                UnaryOp::Atan => va.atan(),
1598                UnaryOp::Acos => va.acos(),
1599                UnaryOp::Sinh => va.sinh(),
1600                UnaryOp::Cosh => va.cosh(),
1601                UnaryOp::Tanh => va.tanh(),
1602                UnaryOp::Asin => va.asin(),
1603                UnaryOp::Acosh => va.acosh(),
1604                UnaryOp::Asinh => va.asinh(),
1605                UnaryOp::Atanh => va.atanh(),
1606                UnaryOp::Erf => crate::nl_tape::erf(va),
1607                UnaryOp::XLogX => crate::nl_tape::xlogx(va),
1608            }
1609        }
1610        Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
1611        Expr::MinList(args) => args
1612            .iter()
1613            .map(|a| eval_expr(a, x))
1614            .fold(Number::INFINITY, Number::min),
1615        Expr::MaxList(args) => args
1616            .iter()
1617            .map(|a| eval_expr(a, x))
1618            .fold(Number::NEG_INFINITY, Number::max),
1619        Expr::Compare(op, a, b) => {
1620            let va = eval_expr(a, x);
1621            let vb = eval_expr(b, x);
1622            let truth = match op {
1623                CmpOp::Lt => va < vb,
1624                CmpOp::Le => va <= vb,
1625                CmpOp::Eq => va == vb,
1626                CmpOp::Ge => va >= vb,
1627                CmpOp::Gt => va > vb,
1628                CmpOp::Ne => va != vb,
1629            };
1630            if truth { 1.0 } else { 0.0 }
1631        }
1632        Expr::And(a, b) => {
1633            if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
1634                1.0
1635            } else {
1636                0.0
1637            }
1638        }
1639        Expr::Or(a, b) => {
1640            if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
1641                1.0
1642            } else {
1643                0.0
1644            }
1645        }
1646        Expr::Not(a) => {
1647            if eval_expr(a, x) == 0.0 {
1648                1.0
1649            } else {
1650                0.0
1651            }
1652        }
1653        Expr::Cond { cond, then_, else_ } => {
1654            if eval_expr(cond, x) != 0.0 {
1655                eval_expr(then_, x)
1656            } else {
1657                eval_expr(else_, x)
1658            }
1659        }
1660        Expr::Cse(body) => eval_expr(body, x),
1661        Expr::Funcall { .. } => panic!(
1662            "eval_expr: AMPL imported function called without an external resolver; \
1663             evaluate through the tape AD path (Tape::build_with_externals) instead"
1664        ),
1665    }
1666}
1667
1668/// Index of the active operand of an n-ary min (`want_min = true`) or
1669/// max (`want_min = false`) list at point `x`: the smallest / largest
1670/// value, with ties resolved to the first such operand (the
1671/// conventional subgradient choice). Returns `None` for an empty list.
1672fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
1673    let mut best: Option<(usize, Number)> = None;
1674    for (i, a) in args.iter().enumerate() {
1675        let v = eval_expr(a, x);
1676        match best {
1677            None => best = Some((i, v)),
1678            Some((_, bv)) => {
1679                // Strict comparison keeps the FIRST extremal operand on
1680                // ties, matching the subgradient convention used by Abs
1681                // and Select elsewhere in the tape.
1682                if (want_min && v < bv) || (!want_min && v > bv) {
1683                    best = Some((i, v));
1684                }
1685            }
1686        }
1687    }
1688    best.map(|(i, _)| i)
1689}
1690
1691/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
1692pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
1693    match e {
1694        Expr::Const(_) => {}
1695        Expr::Var(i) => grad[*i] += seed,
1696        Expr::Binary(op, a, b) => {
1697            let va = eval_expr(a, x);
1698            let vb = eval_expr(b, x);
1699            match op {
1700                BinOp::Add => {
1701                    grad_expr(a, x, seed, grad);
1702                    grad_expr(b, x, seed, grad);
1703                }
1704                BinOp::Sub => {
1705                    grad_expr(a, x, seed, grad);
1706                    grad_expr(b, x, -seed, grad);
1707                }
1708                BinOp::Mul => {
1709                    grad_expr(a, x, seed * vb, grad);
1710                    grad_expr(b, x, seed * va, grad);
1711                }
1712                BinOp::Div => {
1713                    grad_expr(a, x, seed / vb, grad);
1714                    grad_expr(b, x, -seed * va / (vb * vb), grad);
1715                }
1716                BinOp::Pow => {
1717                    // d/da: b * a^(b-1)
1718                    let dpa = vb * va.powf(vb - 1.0);
1719                    grad_expr(a, x, seed * dpa, grad);
1720                    // d/db: a^b * ln(a) (only valid for a>0; simple branch)
1721                    if va > 0.0 {
1722                        let dpb = va.powf(vb) * va.ln();
1723                        grad_expr(b, x, seed * dpb, grad);
1724                    }
1725                }
1726                BinOp::Atan2 => {
1727                    // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
1728                    let d = va * va + vb * vb;
1729                    grad_expr(a, x, seed * vb / d, grad);
1730                    grad_expr(b, x, -seed * va / d, grad);
1731                }
1732                BinOp::CEntropy => {
1733                    grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
1734                    grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
1735                }
1736            }
1737        }
1738        Expr::Unary(op, a) => {
1739            let va = eval_expr(a, x);
1740            let d = match op {
1741                UnaryOp::Neg => -1.0,
1742                UnaryOp::Sqrt => 0.5 / va.sqrt(),
1743                UnaryOp::Log => 1.0 / va,
1744                UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
1745                UnaryOp::Exp => va.exp(),
1746                UnaryOp::Abs => {
1747                    if va > 0.0 {
1748                        1.0
1749                    } else if va < 0.0 {
1750                        -1.0
1751                    } else {
1752                        0.0
1753                    }
1754                }
1755                UnaryOp::Sin => va.cos(),
1756                UnaryOp::Cos => -va.sin(),
1757                UnaryOp::Tan => {
1758                    let t = va.tan();
1759                    1.0 + t * t
1760                }
1761                UnaryOp::Atan => 1.0 / (1.0 + va * va),
1762                UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
1763                UnaryOp::Sinh => va.cosh(),
1764                UnaryOp::Cosh => va.sinh(),
1765                UnaryOp::Tanh => {
1766                    let t = va.tanh();
1767                    1.0 - t * t
1768                }
1769                UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
1770                UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
1771                UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
1772                UnaryOp::Atanh => 1.0 / (1.0 - va * va),
1773                UnaryOp::Erf => crate::nl_tape::erf_d1(va),
1774                UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
1775            };
1776            grad_expr(a, x, seed * d, grad);
1777        }
1778        Expr::Sum(args) => {
1779            for arg in args {
1780                grad_expr(arg, x, seed, grad);
1781            }
1782        }
1783        // min/max are piecewise linear: the seed flows only through the
1784        // currently-active (smallest / largest) operand — a subgradient.
1785        // Ties resolve to the first such operand. Empty list: no operand,
1786        // no derivative (matches the ±inf eval fold).
1787        Expr::MinList(args) => {
1788            if let Some(k) = argmin_argmax(args, x, true) {
1789                grad_expr(&args[k], x, seed, grad);
1790            }
1791        }
1792        Expr::MaxList(args) => {
1793            if let Some(k) = argmin_argmax(args, x, false) {
1794                grad_expr(&args[k], x, seed, grad);
1795            }
1796        }
1797        // Comparisons and logical connectives are piecewise constant:
1798        // zero derivative, so no seed propagates into their operands.
1799        Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
1800        // if-then-else: differentiate only the active branch. The
1801        // branch-switch discontinuity contributes no derivative.
1802        Expr::Cond { cond, then_, else_ } => {
1803            if eval_expr(cond, x) != 0.0 {
1804                grad_expr(then_, x, seed, grad);
1805            } else {
1806                grad_expr(else_, x, seed, grad);
1807            }
1808        }
1809        Expr::Cse(body) => grad_expr(body, x, seed, grad),
1810        Expr::Funcall { .. } => {
1811            panic!("grad_expr: AMPL imported function called without an external resolver")
1812        }
1813    }
1814}
1815
1816/// Walk `e` and insert every `Var(i)` index into `out`.
1817///
1818/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
1819/// identity. Without that this is Θ(2^depth) on a DAG that shares
1820/// subexpressions — each reference re-walks the whole body — and presolve
1821/// calls this on every solve (`get_variables_linearity`). Skipping a
1822/// repeat visit cannot change the answer: `out` is a set, and a second
1823/// walk of the same body inserts exactly the indices the first already did.
1824pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
1825    // `HashSet::new` does not allocate until the first insert, so an
1826    // expression with no CSEs pays nothing for the memo.
1827    let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
1828    collect_vars_memo(e, out, &mut seen);
1829}
1830
1831fn collect_vars_memo(
1832    e: &Expr,
1833    out: &mut BTreeSet<usize>,
1834    seen: &mut std::collections::HashSet<*const Expr>,
1835) {
1836    match e {
1837        Expr::Const(_) => {}
1838        Expr::Var(i) => {
1839            out.insert(*i);
1840        }
1841        Expr::Binary(_, a, b) => {
1842            collect_vars_memo(a, out, seen);
1843            collect_vars_memo(b, out, seen);
1844        }
1845        Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
1846        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1847            for a in args {
1848                collect_vars_memo(a, out, seen);
1849            }
1850        }
1851        // Collect from every child, including the condition: even
1852        // though the comparison/branch-test contributes no derivative,
1853        // the variables it reads are genuinely "used" by the problem,
1854        // and being conservative here only ever adds structural zeros
1855        // to the Jacobian/Hessian (never drops a real nonzero).
1856        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1857            collect_vars_memo(a, out, seen);
1858            collect_vars_memo(b, out, seen);
1859        }
1860        Expr::Not(a) => collect_vars_memo(a, out, seen),
1861        Expr::Cond { cond, then_, else_ } => {
1862            collect_vars_memo(cond, out, seen);
1863            collect_vars_memo(then_, out, seen);
1864            collect_vars_memo(else_, out, seen);
1865        }
1866        Expr::Cse(body) => {
1867            if seen.insert(Arc::as_ptr(body)) {
1868                collect_vars_memo(body, out, seen);
1869            }
1870        }
1871        Expr::Funcall { args, .. } => {
1872            for a in args {
1873                if let FuncallArg::Real(e) = a {
1874                    collect_vars_memo(e, out, seen);
1875                }
1876            }
1877        }
1878    }
1879}
1880
1881// --------------------------------------------------------------------
1882// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
1883// Jacobian, and Hessian. Built once at construction; every solve-time
1884// callback is a tape sweep, no expression-tree recursion.
1885// --------------------------------------------------------------------
1886
1887/// Per-color decoding instruction for `eval_h` Hessian-coloring.
1888/// After a directional Hessian-vector product `compressed = H · s_c`,
1889/// the entry at row `row` came uniquely from column `col` (because
1890/// no two columns of color `c` share any nonzero row), so we
1891/// scatter `compressed[row]` into `values[hess_idx]`.
1892#[derive(Debug, Clone)]
1893struct ColorWrite {
1894    row: u32,
1895    hess_idx: u32,
1896}
1897
1898/// Constraint-block [`HybridTape`]: one local op list per summand plus a
1899/// **shared prelude** holding every CSE body referenced by two or more
1900/// summands, evaluated once per sweep.
1901///
1902/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
1903/// defined variable (`V` segment) referenced from many rows is re-emitted —
1904/// and re-evaluated — once per reference. That is invisible on most models
1905/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
1906/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
1907/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
1908/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
1909/// search. See pounce#476.
1910///
1911/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
1912/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
1913/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
1914/// overhead the flat tapes do not, so each derivative order has to earn
1915/// its switch.
1916#[derive(Debug, Clone)]
1917struct ConHybrid {
1918    tape: HybridTape,
1919    /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
1920    /// Length `m + 1`.
1921    row_start: Vec<usize>,
1922    /// Prelude forward values, sized to `tape.n_prelude_ops()`.
1923    prelude_vals: Vec<f64>,
1924    /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
1925    local_vals: Vec<f64>,
1926    /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
1927    /// value arenas above. `gradient_summand` zeroes only the slots a
1928    /// summand actually reaches, so these are allocated once and reused.
1929    local_adj: Vec<f64>,
1930    prelude_adj: Vec<f64>,
1931    /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
1932    /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
1933    /// unlike `eval_g`, the hybrid Jacobian is not a free win.
1934    use_for_jac: bool,
1935    /// Whether `eval_h` routes the constraint block through the shared
1936    /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
1937    use_for_hess: bool,
1938    // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
1939    // `try_new` after the Hessian coloring exists; cheap enough (a few
1940    // index tables plus one f64 per local op) to build whenever the
1941    // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
1942    /// Forward values of every summand, packed at
1943    /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
1944    /// fills it; every color then reuses the values, mirroring the flat
1945    /// path's forward-once-per-tape structure.
1946    local_vals_all: Vec<f64>,
1947    /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
1948    local_off: Vec<usize>,
1949    /// Constraint row of each summand (the inverse of `row_start`), for
1950    /// the `λ[row]` weight lookup.
1951    summand_row: Vec<u32>,
1952    /// Per color: the summands whose variables fall in that color — the
1953    /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
1954    /// exactly the live (color, summand) pairs.
1955    hess_color_summands: Vec<Vec<u32>>,
1956    /// Per color: the prelude slots that color's summands actually reach,
1957    /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
1958    /// Both prelude sweeps run once per color, so walking the whole
1959    /// prelude each time would cost `n_colors × |prelude|` where the
1960    /// op-ratio gate assumes `|prelude|`; iterating the union of the
1961    /// color's `prelude_reach` sets makes the cost proportional to what
1962    /// is used, so the gate does not need an `n_colors` term. Stored as
1963    /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
1964    /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
1965    /// this struct is the one #552 made O(n²) by holding per-color dense
1966    /// arrays.
1967    hess_color_reach: Vec<u32>,
1968    hess_color_reach_off: Vec<usize>,
1969    /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
1970    prelude_dot: Vec<f64>,
1971    /// First-/second-order prelude adjoint accumulators. NOT shared
1972    /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
1973    /// between-colors invariant (`prelude_reverse_directional`'s
1974    /// consume-and-zero contract), while `gradient_summand` zeroes only
1975    /// the slots it is about to use and leaves them dirty afterwards —
1976    /// sharing the buffer would seed a later `eval_h` with a stale
1977    /// Jacobian adjoint.
1978    hess_prelude_adj: Vec<f64>,
1979    prelude_adj_dot: Vec<f64>,
1980    /// Local tangent / second-order adjoint arenas, sized to
1981    /// `tape.max_summand_ops()`.
1982    local_dot: Vec<f64>,
1983    local_adj_dot: Vec<f64>,
1984}
1985
1986/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
1987/// shared-CSE prelude.
1988///
1989/// `eval_g` takes the hybrid path unconditionally because it only needs
1990/// *values*: the prelude is swept once for the whole constraint block and
1991/// the saving is the full op-count ratio. The Jacobian is different. Each
1992/// row needs its own gradient, so only the forward sweep can be shared —
1993/// the reverse sweep still walks each summand's `prelude_reach`
1994/// separately, and it pays a per-op cost the flat tape does not: a nested
1995/// `SummandOp` dispatch and an indirected walk over a reach list instead
1996/// of a straight loop over a contiguous `Vec<TapeOp>`.
1997///
1998/// So the hybrid Jacobian wins only when the shared bodies are large
1999/// enough for the halved forward sweep to outweigh that overhead.
2000/// Measured on chain models at CSE redundancy 40, varying the body size
2001/// (`eval_jac_g`, flat → hybrid):
2002///
2003/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
2004/// |---|---|---|---|---|---|---|---|---|
2005/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
2006///
2007/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
2008/// a model that does not clearly benefit stays on the flat path. For
2009/// reference `robot_a` (#476) measures 4.03×.
2010const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
2011
2012/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
2013/// block through the shared-CSE prelude (issue #557).
2014///
2015/// The Hessian shares **both** second-order sweeps of the prelude, not just
2016/// the forward one: the coloring hands every summand of a color the same
2017/// seed vector, so the prelude forward tangent runs once per color, and —
2018/// because reverse-over-tangent is linear in its adjoint seeds — the
2019/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
2020/// unit-weight prelude reverse sweep per color. That is why its crossover
2021/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
2022/// Jacobian can only share the forward half, and per-row gradients forbid
2023/// batching its reverse sweeps at all.
2024///
2025/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
2026/// bodies), varying the body size — the same protocol as the Jacobian
2027/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
2028/// flat/hybrid pairs per point**, with the observed range, because
2029/// single runs on a shared machine are not reproducible to the precision
2030/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
2031/// single ratio:
2032///
2033/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
2034/// |---|---|---|---|---|---|---|---|---|
2035/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
2036/// | 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 |
2037///
2038/// The gate sits at 3.0: that is the lowest ratio where **every** sample
2039/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
2040/// break-even and individual runs lose. Setting it there costs only a
2041/// marginal forgone gain — below the gate `eval_h` stays on the flat path
2042/// bit-identically, so a gate placed too high is merely conservative while
2043/// one placed too low risks a real regression. For reference `robot_a`
2044/// (#476) measures 4.03×.
2045const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
2046
2047// `Clone` supports the batched-solve path (pounce#126): one parsed
2048// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
2049// the clone is cheap relative to a solve) and each clone gets its own
2050// bound / starting-point overrides via [`NlTnlp::variant`].
2051#[derive(Debug, Clone)]
2052pub struct NlTnlp {
2053    prob: NlProblem,
2054    /// Per-summand objective tapes (one `Tape` per top-level
2055    /// summand after `split_top_sums`).
2056    obj_tapes: Vec<Tape>,
2057    /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
2058    /// one `Tape` per summand of constraint `i`.
2059    con_tapes: Vec<Vec<Tape>>,
2060    /// Constraint-block tape with a **shared** CSE prelude, used by
2061    /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
2062    /// `eval_g` on the per-summand `con_tapes` above.
2063    con_hybrid: Option<ConHybrid>,
2064    /// Lower-triangle Hessian sparsity (row >= col), one entry per
2065    /// structurally nonzero second derivative in the Lagrangian.
2066    h_irow: Vec<i32>,
2067    h_jcol: Vec<i32>,
2068    /// Per-row sorted variable indices for the constraint Jacobian.
2069    jac_cols: Vec<Vec<usize>>,
2070    jac_nnz: usize,
2071    /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
2072    /// is in color `c`, else `0.0`. Each color is a set of
2073    /// variables whose Hessian columns have pairwise-disjoint
2074    /// nonzero rows; one directional H·s product per color
2075    /// recovers all those columns simultaneously. Dense for
2076    /// O(1) lookup in the per-op forward tangent.
2077    seeds: Vec<Vec<f64>>,
2078    /// Per-color decoding table: for each `(row, hess_idx)` entry,
2079    /// scatter `compressed_c[row] -> values[hess_idx]` after the
2080    /// per-color directional product.
2081    decoding: Vec<Vec<ColorWrite>>,
2082    /// For each objective tape: the distinct colors of vars it
2083    /// references. Lets us skip tape × color pairs where the tape
2084    /// has zero overlap with the color's seed.
2085    obj_tape_colors: Vec<Vec<u32>>,
2086    /// Same as `obj_tape_colors` but per constraint × summand.
2087    con_tape_colors: Vec<Vec<Vec<u32>>>,
2088    /// Color of each variable's Hessian column, `u32::MAX` for a column
2089    /// that needs no pass of its own. Kept so
2090    /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
2091    /// pass in `compressed`.
2092    var_color: Vec<u32>,
2093    /// Columns peeled out of the conflict structure and given singleton
2094    /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
2095    peeled_cols: Vec<u32>,
2096    final_x: Option<Vec<Number>>,
2097    final_obj: Number,
2098    /// Converged constraint multipliers (length `m`, original `.nl` row
2099    /// order, user convention), captured from the same `finalize_solution`
2100    /// call as `final_x`. Kept so a frontend can write the `.sol` dual
2101    /// block without re-deriving it from the algorithm's internal `y_c` /
2102    /// `y_d` split and scaling.
2103    final_lambda: Option<Vec<Number>>,
2104    /// Converged bound multipliers (length `n` each, Ipopt's internal
2105    /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
2106    /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
2107    /// reads for reduced costs.
2108    final_z_l: Option<Vec<Number>>,
2109    final_z_u: Option<Vec<Number>>,
2110    /// Per-row Jacobian accumulator (length n).
2111    scratch_row_grad: Vec<f64>,
2112    /// Scratch buffers for `Tape::hessian_directional` (each sized
2113    /// to `max_tape_n`).
2114    vals_scratch: Vec<f64>,
2115    dot_scratch: Vec<f64>,
2116    adj_scratch: Vec<f64>,
2117    adj_dot_scratch: Vec<f64>,
2118    /// Per-color compressed Hessian-vector results, sized to
2119    /// `prob.n`. Reused across `eval_h` calls but allocated once.
2120    compressed: Vec<Vec<f64>>,
2121    /// Per-direction "carries signal" mask for `hessian_vector_products`,
2122    /// kept here rather than allocated per call. This crate holds an
2123    /// explicit no-per-call-allocation line on the tape sweeps (see
2124    /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
2125    /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
2126    /// Krylov iteration.
2127    hvp_live: Vec<bool>,
2128}
2129
2130// ---------------------------------------------------------------------
2131// Human-readable equation rendering (`print equation` in the debugger).
2132//
2133// Turns a parsed constraint back into infix text using the model's
2134// variable / constraint names, so the debugger can show the actual
2135// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
2136// bare row index. This is the "print the specific equation, with
2137// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
2138// argue makes equation-oriented model diagnostics actionable.
2139//
2140// The renderer is intentionally separate from the evaluation `Tape`:
2141// tapes are lossy for display (CSEs flattened, externals opaque),
2142// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
2143// ---------------------------------------------------------------------
2144
2145/// Binding strength for parenthesization. Higher binds tighter.
2146const P_ADD: u8 = 10;
2147const P_MUL: u8 = 20;
2148const P_NEG: u8 = 30;
2149const P_POW: u8 = 40;
2150const P_ATOM: u8 = 100;
2151
2152/// Format a numeric literal compactly: integers without a trailing `.0`,
2153/// everything else via the shortest round-tripping `f64` form.
2154fn fmt_num(x: Number) -> String {
2155    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
2156        format!("{}", x as i64)
2157    } else {
2158        format!("{x}")
2159    }
2160}
2161
2162/// Display label for variable `i`: its `.col` name when present, else
2163/// `x[i]`.
2164fn var_label(i: usize, var_names: &[String]) -> String {
2165    match var_names.get(i) {
2166        Some(s) if !s.is_empty() => s.clone(),
2167        _ => format!("x[{i}]"),
2168    }
2169}
2170
2171/// Precedence of an expression's top operator (for child wrapping).
2172fn expr_prec(e: &Expr) -> u8 {
2173    match e {
2174        Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
2175        Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
2176        Expr::Unary(UnaryOp::Neg, _) => P_NEG,
2177        Expr::Binary(BinOp::Pow, ..) => P_POW,
2178        Expr::Cse(inner) => expr_prec(inner),
2179        // Everything else renders as an atom / `f(...)` form.
2180        _ => P_ATOM,
2181    }
2182}
2183
2184/// Render an expression as infix text, using `var_names` for variable
2185/// labels where available (`x[i]` otherwise).
2186///
2187/// The debugger reaches the renderer through the constraint/objective
2188/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
2189/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
2190/// able to *see* the expression you just built is most of the debugging
2191/// story.
2192///
2193/// `Cse` bodies are inlined at every occurrence, so the output of a
2194/// heavily-shared DAG can be far larger than the DAG itself. Callers
2195/// rendering user-built expressions should bound the input first.
2196pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
2197    render_expr(e, var_names, &[])
2198}
2199
2200/// Render `e`, wrapping in parentheses iff its precedence is looser than
2201/// `min_prec`.
2202fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
2203    let s = render_expr(e, vn, funcs);
2204    if expr_prec(e) < min_prec {
2205        format!("({s})")
2206    } else {
2207        s
2208    }
2209}
2210
2211fn unary_name(op: UnaryOp) -> &'static str {
2212    match op {
2213        UnaryOp::Neg => "-",
2214        UnaryOp::Sqrt => "sqrt",
2215        UnaryOp::Log => "log",
2216        UnaryOp::Exp => "exp",
2217        UnaryOp::Abs => "abs",
2218        UnaryOp::Sin => "sin",
2219        UnaryOp::Cos => "cos",
2220        UnaryOp::Log10 => "log10",
2221        UnaryOp::Tan => "tan",
2222        UnaryOp::Atan => "atan",
2223        UnaryOp::Acos => "acos",
2224        UnaryOp::Sinh => "sinh",
2225        UnaryOp::Cosh => "cosh",
2226        UnaryOp::Tanh => "tanh",
2227        UnaryOp::Asin => "asin",
2228        UnaryOp::Acosh => "acosh",
2229        UnaryOp::Asinh => "asinh",
2230        UnaryOp::Atanh => "atanh",
2231        UnaryOp::Erf => "erf",
2232        // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
2233        // the rendered text is read by humans debugging a model and must not
2234        // imply a sign the op does not have.
2235        UnaryOp::XLogX => "xlogx",
2236    }
2237}
2238
2239fn cmp_sym(op: CmpOp) -> &'static str {
2240    match op {
2241        CmpOp::Lt => "<",
2242        CmpOp::Le => "<=",
2243        CmpOp::Eq => "==",
2244        CmpOp::Ge => ">=",
2245        CmpOp::Gt => ">",
2246        CmpOp::Ne => "!=",
2247    }
2248}
2249
2250/// Append an additive sub-term with a tidy sign: a rendered term that
2251/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
2252/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
2253/// the term is itself a sum. The first term is emitted verbatim.
2254fn push_additive(out: &mut String, rendered: &str, first: bool) {
2255    if first {
2256        out.push_str(rendered);
2257    } else if let Some(rest) = rendered.strip_prefix('-') {
2258        out.push_str(" - ");
2259        out.push_str(rest);
2260    } else {
2261        out.push_str(" + ");
2262        out.push_str(rendered);
2263    }
2264}
2265
2266/// Render an [`Expr`] DAG to infix text using model names.
2267fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
2268    match e {
2269        Expr::Const(c) => fmt_num(*c),
2270        Expr::Var(i) => var_label(*i, vn),
2271        Expr::Binary(op, l, r) => match op {
2272            BinOp::Add => {
2273                let mut s = render_prec(l, P_ADD, vn, funcs);
2274                push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
2275                s
2276            }
2277            // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
2278            BinOp::Sub => format!(
2279                "{} - {}",
2280                render_prec(l, P_ADD, vn, funcs),
2281                render_prec(r, P_ADD + 1, vn, funcs)
2282            ),
2283            BinOp::Mul => format!(
2284                "{}*{}",
2285                render_prec(l, P_MUL, vn, funcs),
2286                render_prec(r, P_MUL, vn, funcs)
2287            ),
2288            BinOp::Div => format!(
2289                "{}/{}",
2290                render_prec(l, P_MUL, vn, funcs),
2291                render_prec(r, P_MUL + 1, vn, funcs)
2292            ),
2293            // Pow is right-associative: tighten the left operand instead.
2294            BinOp::Pow => format!(
2295                "{}^{}",
2296                render_prec(l, P_POW + 1, vn, funcs),
2297                render_prec(r, P_POW, vn, funcs)
2298            ),
2299            BinOp::Atan2 => format!(
2300                "atan2({}, {})",
2301                render_expr(l, vn, funcs),
2302                render_expr(r, vn, funcs)
2303            ),
2304            BinOp::CEntropy => format!(
2305                "centropy({}, {})",
2306                render_expr(l, vn, funcs),
2307                render_expr(r, vn, funcs)
2308            ),
2309        },
2310        Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
2311        Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
2312        Expr::Sum(xs) => {
2313            if xs.is_empty() {
2314                "0".to_string()
2315            } else {
2316                let mut s = String::new();
2317                for (k, x) in xs.iter().enumerate() {
2318                    push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
2319                }
2320                s
2321            }
2322        }
2323        Expr::Cse(inner) => render_expr(inner, vn, funcs),
2324        Expr::Funcall { id, args } => {
2325            let name = funcs
2326                .iter()
2327                .find(|f| f.id == *id)
2328                .map(|f| f.name.clone())
2329                .unwrap_or_else(|| format!("extern#{id}"));
2330            let parts: Vec<String> = args
2331                .iter()
2332                .map(|a| match a {
2333                    FuncallArg::Real(x) => render_expr(x, vn, funcs),
2334                    FuncallArg::Str(s) => format!("{s:?}"),
2335                })
2336                .collect();
2337            format!("{name}({})", parts.join(", "))
2338        }
2339        Expr::Compare(op, a, b) => format!(
2340            "({} {} {})",
2341            render_expr(a, vn, funcs),
2342            cmp_sym(*op),
2343            render_expr(b, vn, funcs)
2344        ),
2345        Expr::And(a, b) => format!(
2346            "({} && {})",
2347            render_expr(a, vn, funcs),
2348            render_expr(b, vn, funcs)
2349        ),
2350        Expr::Or(a, b) => format!(
2351            "({} || {})",
2352            render_expr(a, vn, funcs),
2353            render_expr(b, vn, funcs)
2354        ),
2355        Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
2356        Expr::Cond { cond, then_, else_ } => format!(
2357            "if({}, {}, {})",
2358            render_expr(cond, vn, funcs),
2359            render_expr(then_, vn, funcs),
2360            render_expr(else_, vn, funcs)
2361        ),
2362        Expr::MinList(xs) => format!(
2363            "min({})",
2364            xs.iter()
2365                .map(|x| render_expr(x, vn, funcs))
2366                .collect::<Vec<_>>()
2367                .join(", ")
2368        ),
2369        Expr::MaxList(xs) => format!(
2370            "max({})",
2371            xs.iter()
2372                .map(|x| render_expr(x, vn, funcs))
2373                .collect::<Vec<_>>()
2374                .join(", ")
2375        ),
2376    }
2377}
2378
2379/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
2380/// `a + -2*b`). Returns `""` when there are no linear terms.
2381fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
2382    let mut out = String::new();
2383    // The `.nl` linear part carries an entry for every variable in the
2384    // row's Jacobian, including a 0 coefficient for variables that appear
2385    // only *nonlinearly* (they're rendered in the nonlinear part). Skip
2386    // those zeros so the equation reads as written, not as a sparsity map.
2387    let mut first = true;
2388    for (var, coef) in linear {
2389        if *coef == 0.0 {
2390            continue;
2391        }
2392        let neg = *coef < 0.0;
2393        let mag = coef.abs();
2394        let term = if mag == 1.0 {
2395            var_label(*var, vn)
2396        } else {
2397            format!("{}*{}", fmt_num(mag), var_label(*var, vn))
2398        };
2399        if first {
2400            if neg {
2401                out.push('-');
2402            }
2403            out.push_str(&term);
2404            first = false;
2405        } else {
2406            out.push_str(if neg { " - " } else { " + " });
2407            out.push_str(&term);
2408        }
2409    }
2410    out
2411}
2412
2413/// Render the constraint body (linear + nonlinear parts combined).
2414fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
2415    let mut s = render_linear(linear, &prob.var_names);
2416    let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
2417    if !nl_is_zero {
2418        let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
2419        if s.is_empty() {
2420            s = nl;
2421        } else {
2422            push_additive(&mut s, &nl, false);
2423        }
2424    }
2425    if s.is_empty() {
2426        s = "0".to_string();
2427    }
2428    s
2429}
2430
2431/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
2432/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
2433/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
2434pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
2435    let body = render_body(&prob.con_linear[k], &prob.con_nonlinear[k], prob);
2436    let lo = prob.g_l[k];
2437    let hi = prob.g_u[k];
2438    const INF: Number = 1.0e19;
2439    let has_lo = lo > -INF;
2440    let has_hi = hi < INF;
2441    match (has_lo, has_hi) {
2442        (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
2443        (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
2444        (true, false) => format!("{body} >= {}", fmt_num(lo)),
2445        (false, true) => format!("{body} <= {}", fmt_num(hi)),
2446        (false, false) => format!("{body}  (free)"),
2447    }
2448}
2449
2450/// Render every constraint to text, index-aligned to `g` (original `.nl`
2451/// row order). Used to build the debugger's static equation book.
2452pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
2453    (0..prob.m)
2454        .map(|k| render_constraint_equation(prob, k))
2455        .collect()
2456}
2457
2458/// Structural sparsity of the constraint Jacobian as flat 0-based
2459/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
2460/// structurally depends on — the union of its linear support and the
2461/// `Var(i)` indices appearing anywhere in its nonlinear tree
2462/// ([`collect_vars`]). Sorted and deduplicated within each row.
2463///
2464/// This is the input to the debugger's Dulmage–Mendelsohn
2465/// structural-rank check (`diagnose`), which names the over-determined
2466/// (candidate redundant / inconsistent) equations and under-determined
2467/// variables. Naming the dependent rows — rather than reporting
2468/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
2469/// equation-oriented model debugging. See
2470/// <https://doi.org/10.69997/sct.147875>.
2471pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
2472    let mut irow: Vec<Index> = Vec::new();
2473    let mut jcol: Vec<Index> = Vec::new();
2474    let mut support: BTreeSet<usize> = BTreeSet::new();
2475    for k in 0..prob.m {
2476        support.clear();
2477        for &(j, _coef) in &prob.con_linear[k] {
2478            support.insert(j);
2479        }
2480        collect_vars(&prob.con_nonlinear[k], &mut support);
2481        for &j in &support {
2482            irow.push(k as Index);
2483            jcol.push(j as Index);
2484        }
2485    }
2486    (irow, jcol)
2487}
2488
2489/// Flatten an additive expression tree into independent summand
2490/// expressions, each of which becomes its own Hessian tape.
2491///
2492/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
2493/// each summand tape once *per color the summand touches*, so the
2494/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
2495/// small (few variables → few colors) is what makes a sparse Hessian
2496/// cheap. A single fused tape spanning all `n` variables, by
2497/// contrast, is walked once per color → `O(n · tape_len)`, which on a
2498/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
2499/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
2500///
2501/// We therefore descend through the *affine* envelope of the sum, not
2502/// just `+`/`Sum`:
2503///
2504///   * `Neg(x)`            → split `x`, negate each summand
2505///   * `Sub(l, r)`         → split `l`; split `r`, negate each summand
2506///   * `c * x` / `x * c`   → split `x`, scale each summand by `c`
2507///   * `x / c`             → split `x`, scale each summand by `1/c`
2508///
2509/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
2510/// least-squares / max-entropy shapes) still decomposes to its leaf
2511/// terms instead of collapsing into one giant tape. The carried
2512/// `factor` is materialised onto each leaf only when it differs from
2513/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
2514/// is unchanged and the per-summand op count grows by at most one.
2515fn split_top_sums(expr: &Expr) -> Vec<Expr> {
2516    let mut out = Vec::new();
2517    fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
2518        if factor == 1.0 {
2519            out.push(e.clone());
2520        } else if factor == -1.0 {
2521            out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
2522        } else {
2523            out.push(Expr::Binary(
2524                BinOp::Mul,
2525                Box::new(Expr::Const(factor)),
2526                Box::new(e.clone()),
2527            ));
2528        }
2529    }
2530    fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
2531        match e {
2532            Expr::Sum(terms) => {
2533                for t in terms {
2534                    go(t, factor, out);
2535                }
2536            }
2537            Expr::Binary(BinOp::Add, l, r) => {
2538                go(l, factor, out);
2539                go(r, factor, out);
2540            }
2541            Expr::Binary(BinOp::Sub, l, r) => {
2542                go(l, factor, out);
2543                go(r, -factor, out);
2544            }
2545            Expr::Unary(UnaryOp::Neg, x) => {
2546                go(x, -factor, out);
2547            }
2548            // Affine scaling: distribute a constant coefficient into
2549            // the summands so a leading `c·(Σ …)` still splits.
2550            Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
2551                (Expr::Const(c), _) => go(r, factor * c, out),
2552                (_, Expr::Const(c)) => go(l, factor * c, out),
2553                _ => push_leaf(e, factor, out),
2554            },
2555            Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
2556                Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
2557                _ => push_leaf(e, factor, out),
2558            },
2559            _ => push_leaf(e, factor, out),
2560        }
2561    }
2562    go(expr, 1.0, &mut out);
2563    if out.is_empty() {
2564        out.push(Expr::Const(0.0));
2565    }
2566    out
2567}
2568
2569/// Greedy column coloring of a symmetric sparsity pattern stored
2570/// as lower-triangle pairs.
2571///
2572/// Builds the column-intersection graph: columns `c1` and `c2` are
2573/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
2574/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
2575/// satisfies the direct-recovery condition for symmetric Hessians
2576/// (Coleman-Moré): for any color, the columns it contains have
2577/// pairwise disjoint row supports, so a single H·s product
2578/// recovers them all unambiguously.
2579///
2580/// Returns `(var_color, n_colors)` where `var_color[k]` is the
2581/// color assigned to variable `k`, or `u32::MAX` for variables
2582/// not in any Hessian pair (they contribute nothing and don't
2583/// need a color).
2584/// A column is treated as **dense** — and peeled out of the coloring —
2585/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
2586/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
2587/// factor keeps uniformly-dense Hessians (where every column looks like
2588/// every other) on the plain coloring path, and the absolute floor stops
2589/// a very sparse average from declaring a 10-entry column "dense".
2590const DENSE_COL_FACTOR: usize = 16;
2591const DENSE_COL_MIN: usize = 32;
2592/// Largest relative error a peeled column may inflict on the smallest
2593/// entry recovered from its pass before
2594/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
2595///
2596/// The ratio is a worst-case bound — the pass's roundoff floor over the
2597/// smallest entry read out of it — and it is a *loose* one, by an amount
2598/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
2599/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
2600/// measures 4e-16. The cut is therefore calibrated on the corpus, not
2601/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
2602/// models that peel anything, the highest bound on a column that recovers
2603/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
2604/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
2605/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
2606///
2607/// 1e-8 sits below both, which deliberately buys correctness with speed:
2608/// the errors are asymmetric, since a false veto costs one model a coloring
2609/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
2610/// while a missed veto costs a solve its certificate. Five of the 56 take a
2611/// veto they do not need; none takes a wrong answer. Raising this constant
2612/// to buy those five back would put the cut inside a 2.8x window measured
2613/// on two model families, which is not a margin worth trading a certificate
2614/// for.
2615const PEEL_MAX_REL_ERR: f64 = 1e-8;
2616/// Hard cap on how many columns get peeled, applied on top of the
2617/// pay-for-itself rule in [`select_peeled_cols`].
2618const MAX_PEELED_COLS: usize = 256;
2619
2620/// Lower bound on the color count that results from peeling `peeled`.
2621///
2622/// Peeling costs one color per peeled column. On what remains, any row
2623/// with `d` surviving entries makes those `d` columns pairwise
2624/// conflicting, so the greedy walk needs at least `d` colors. Hence
2625/// `|peeled| + max surviving row degree` is a lower bound on the total,
2626/// computable in one O(nnz) pass — no coloring required.
2627///
2628/// The bound is what makes the choice decidable at all: the plain
2629/// coloring cannot be run as a comparison baseline, because on the
2630/// one-dense-row case the plain walk is itself O(n^2) — precisely the
2631/// blowup peeling exists to avoid.
2632fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
2633    let mut deg = vec![0usize; n];
2634    for &(i, j) in lower_pairs {
2635        if peeled[i] || peeled[j] {
2636            continue;
2637        }
2638        deg[j] += 1;
2639        if i != j {
2640            deg[i] += 1;
2641        }
2642    }
2643    let n_peeled = peeled.iter().filter(|&&p| p).count();
2644    n_peeled + deg.iter().copied().max().unwrap_or(0)
2645}
2646
2647/// Choose which of the candidate dense columns to actually peel.
2648///
2649/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
2650/// top `k` candidates by degree, over a doubling ladder of `k` up to
2651/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
2652/// so peeling has to earn its colors.
2653///
2654/// **Why not just truncate an over-long candidate list.** Cutting the
2655/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
2656/// columns that miss the cut stay in the conflict structure, so the base
2657/// color count is untouched and the singleton colors are pure addition.
2658/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
2659/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
2660/// regression in exactly the quantity peeling exists to reduce. The
2661/// bound above sees it: peeling `k` of several thousand equal-degree
2662/// columns leaves the surviving max degree unchanged, so every `k > 0`
2663/// scores strictly worse than peeling nothing.
2664///
2665/// **Why not a simple degree rule.** "Peel only columns denser than some
2666/// fraction of `n`" would refuse three rows of degree 10,000 in a
2667/// 200,000-variable model, where peeling three columns takes the
2668/// coloring from >= 10,000 down to a handful. The win depends on what
2669/// peeling leaves behind, not on the peeled column's degree alone.
2670fn select_peeled_cols(
2671    n: usize,
2672    lower_pairs: &[(usize, usize)],
2673    deg: &[usize],
2674    mut candidates: Vec<usize>,
2675) -> Vec<usize> {
2676    if candidates.is_empty() {
2677        return candidates;
2678    }
2679    // Worst offenders first; they remove the most conflict per color spent.
2680    candidates.sort_unstable_by(|&a, &b| deg[b].cmp(&deg[a]).then(a.cmp(&b)));
2681    candidates.truncate(MAX_PEELED_COLS);
2682
2683    let mut mask = vec![false; n];
2684    let mut best_k = 0usize;
2685    // Peeling nothing: the bound is just the largest row degree.
2686    let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
2687
2688    // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
2689    // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
2690    // mask only ever gains entries, so each step just marks the new slice.
2691    let mut marked = 0usize;
2692    let mut k = 1usize;
2693    loop {
2694        let k_now = k.min(candidates.len());
2695        for &j in &candidates[marked..k_now] {
2696            mask[j] = true;
2697        }
2698        marked = k_now;
2699        let bound = peel_color_bound(n, lower_pairs, &mask);
2700        if bound < best_bound {
2701            best_bound = bound;
2702            best_k = k_now;
2703        }
2704        if k_now == candidates.len() {
2705            break;
2706        }
2707        k *= 2;
2708    }
2709
2710    candidates.truncate(best_k);
2711    candidates
2712}
2713
2714/// Greedy distance-1 coloring of the Hessian's column-intersection
2715/// graph, with **dense columns peeled out**.
2716///
2717/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
2718/// marks a column that needs no directional product of its own: either
2719/// it has no Hessian entries at all, or every entry it has is recovered
2720/// from a peeled column's pass (see below).
2721///
2722/// # Why peeling
2723///
2724/// The plain coloring rule is "two columns may share a color when they
2725/// have no common nonzero row". A single **dense row** — one variable
2726/// multiplying a sum over all the others, a total-cost variable, a
2727/// shared design parameter — puts a nonzero in *every* column at that
2728/// row, so every pair of columns conflicts and the greedy walk hands out
2729/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
2730/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
2731/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
2732/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
2733///
2734/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
2735/// own singleton color: one directional product with seed `e_d` recovers
2736/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
2737/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
2738/// at row `j` of that same pass. So row `d` no longer constrains any
2739/// other column's color and is dropped from the conflict structure, and
2740/// the remaining columns colour on their genuine sparsity. On the
2741/// one-dense-row case above this takes `n_colors` from `n` to a handful.
2742///
2743/// # What peeling costs, and `peel_veto`
2744///
2745/// Recovering `H[d, j]` from column `d`'s pass is exact in real
2746/// arithmetic but not in floating point: the pass is accumulated at the
2747/// scale of the whole dense column, so every entry read out of it carries
2748/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
2749/// ordinary path — column `j`'s own pass — would have left a floor of
2750/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
2751/// dense column is well scaled, and they do on every peel-firing model in
2752/// the benchmark corpus but one. Where a peeled column spans a wide
2753/// dynamic range, though, that floor swamps its small entries: a column
2754/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
2755///
2756/// Structure cannot see this — it is a property of the values — so
2757/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
2758/// and passes the offenders back here in `peel_veto`, which bars them
2759/// from being peeled again. A vetoed column is colored normally, its row
2760/// returns to the conflict structure, and its entries go back to the
2761/// accurate path.
2762fn greedy_hessian_coloring(
2763    n: usize,
2764    lower_pairs: &[(usize, usize)],
2765    peel_veto: &[bool],
2766) -> (Vec<u32>, usize, Vec<bool>) {
2767    if n == 0 {
2768        return (Vec::new(), 0, Vec::new());
2769    }
2770
2771    // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
2772    // i >= j contributes row i to column j and row j to column i; a
2773    // diagonal contributes once.
2774    let mut deg = vec![0usize; n];
2775    for &(i, j) in lower_pairs {
2776        deg[j] += 1;
2777        if i != j {
2778            deg[i] += 1;
2779        }
2780    }
2781
2782    // Pick the dense columns to peel.
2783    let total: usize = deg.iter().sum();
2784    let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
2785    let mut peeled = vec![false; n];
2786    let candidates: Vec<usize> = (0..n)
2787        .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
2788        .collect();
2789    let dense = select_peeled_cols(n, lower_pairs, &deg, candidates);
2790    for &j in &dense {
2791        peeled[j] = true;
2792    }
2793
2794    // Conflict structure over the *non-peeled* columns only. Pairs with
2795    // a peeled endpoint are recovered from that endpoint's own pass, so
2796    // they neither need a color nor constrain one.
2797    let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
2798    let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
2799    for &(i, j) in lower_pairs {
2800        if peeled[i] || peeled[j] {
2801            continue;
2802        }
2803        col_rows[j].push(i as u32);
2804        row_cols[i].push(j as u32);
2805        if i != j {
2806            col_rows[i].push(j as u32);
2807            row_cols[j].push(i as u32);
2808        }
2809    }
2810
2811    let mut var_color = vec![u32::MAX; n];
2812    let mut forbidden = vec![u32::MAX; n + 1];
2813    let mut n_colors: u32 = 0;
2814
2815    for j in 0..n {
2816        // Peeled columns are colored below; a column with no surviving
2817        // Hessian entries needs no color at all.
2818        if peeled[j] || col_rows[j].is_empty() {
2819            continue;
2820        }
2821        // Mark colors used by any column sharing a row with `j`.
2822        // Row-of-col -> col-in-row visit pattern collects all
2823        // distance-1 neighbors in the column-intersection graph.
2824        for &r in &col_rows[j] {
2825            for &c in &row_cols[r as usize] {
2826                if c as usize == j {
2827                    continue;
2828                }
2829                let cc = var_color[c as usize];
2830                if cc != u32::MAX {
2831                    forbidden[cc as usize] = j as u32;
2832                }
2833            }
2834        }
2835        // First color not stamped with `j as u32`.
2836        let mut chosen: u32 = 0;
2837        while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
2838            chosen += 1;
2839        }
2840        var_color[j] = chosen;
2841        if chosen + 1 > n_colors {
2842            n_colors = chosen + 1;
2843        }
2844    }
2845
2846    // One singleton color per peeled column, appended after the shared
2847    // ones so the non-peeled numbering is untouched.
2848    for &j in &dense {
2849        var_color[j] = n_colors;
2850        n_colors += 1;
2851    }
2852
2853    (var_color, n_colors as usize, peeled)
2854}
2855
2856/// Everything downstream of the Hessian coloring: seed vectors, the
2857/// per-color decode table, the per-tape color sets, and the shared-CSE
2858/// per-color summand / prelude-reach tables.
2859///
2860/// Split out of [`NlTnlp::new`] because
2861/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
2862/// time, with a peel veto in hand, once it has seen real Hessian values.
2863fn build_color_tables(
2864    n: usize,
2865    m: usize,
2866    lower_pairs: &[(usize, usize)],
2867    peel_veto: &[bool],
2868    obj_tapes: &[Tape],
2869    con_tapes: &[Vec<Tape>],
2870    con_hybrid: Option<&mut ConHybrid>,
2871) -> ColorTables {
2872    // Hessian column coloring. The chromatic number of the
2873    // column-intersection graph bounds how many directional
2874    // Hessian-vector products we need per `eval_h` call —
2875    // typically O(stencil) for PDE-mesh problems.
2876    let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, lower_pairs, peel_veto);
2877
2878    // Per-color seed vectors (dense for O(1) Var lookup in
2879    // `Tape::hessian_directional`).
2880    let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
2881    for (k, &c) in var_color.iter().enumerate() {
2882        if c != u32::MAX {
2883            seeds[c as usize][k] = 1.0;
2884        }
2885    }
2886
2887    // Per-color decoding table. For each lower-tri pair (i, j)
2888    // with i >= j, the entry belongs to column j's color: after
2889    // computing compressed_{c_j} = (H · s_{c_j}), the value at
2890    // row i is exactly H[i, j] (coloring guarantees no other
2891    // column in c_j has a nonzero at row i).
2892    // Built straight from `lower_pairs`, which is sorted, so each
2893    // color's table is in ascending `hess_idx` order and the decode
2894    // scatter walks `values` forward instead of hopping (the old
2895    // build drained a `HashMap`, whose iteration order is arbitrary).
2896    let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
2897    for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
2898        // Which directional product recovers H[i, j]? Column `j`'s,
2899        // read at row `i` — except when `i` is a peeled column and
2900        // `j` is not: then `j` may have no color of its own, and the
2901        // entry is already in column `i`'s pass at row `j`, since
2902        // H[i, j] == H[j, i].
2903        let (c, row) = if peeled[i] && !peeled[j] {
2904            (var_color[i], j)
2905        } else {
2906            (var_color[j], i)
2907        };
2908        debug_assert!(
2909            c != u32::MAX,
2910            "Hessian pair ({i}, {j}) at index {idx} has no color"
2911        );
2912        decoding[c as usize].push(ColorWrite {
2913            row: row as u32,
2914            hess_idx: idx as u32,
2915        });
2916    }
2917
2918    // Per-tape distinct color set: for each tape, the colors
2919    // its variables fall into. `eval_h` loops over only these
2920    // (tape, color) pairs instead of n_tapes × n_colors.
2921    let tape_colors = |t: &Tape| -> Vec<u32> {
2922        let mut s: Vec<u32> = t
2923            .variables()
2924            .into_iter()
2925            .map(|v| var_color[v])
2926            .filter(|&c| c != u32::MAX)
2927            .collect();
2928        s.sort_unstable();
2929        s.dedup();
2930        s
2931    };
2932    let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
2933    let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
2934        .iter()
2935        .map(|row| row.iter().map(tape_colors).collect())
2936        .collect();
2937
2938    // Shared-CSE Hessian tables (issue #557): the per-color summand
2939    // lists, row lookup, and packed forward-value arena `eval_h`'s
2940    // hybrid path walks. Built whenever the hybrid tape is — not just
2941    // above the gate — so flipping `use_for_hess` on (tests, the
2942    // force env var) needs no extra setup; the cost is one f64 per
2943    // local op plus small index tables.
2944    if let Some(h) = con_hybrid {
2945        let n_sum = h.tape.n_summands();
2946        let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
2947        let mut acc = 0usize;
2948        for s in &h.tape.summands {
2949            local_off.push(acc);
2950            acc += s.ops.len();
2951        }
2952        local_off.push(acc);
2953        h.local_vals_all = vec![0.0; acc];
2954        h.local_off = local_off;
2955
2956        let mut summand_row = vec![0u32; n_sum];
2957        for i in 0..m {
2958            for si in h.row_start[i]..h.row_start[i + 1] {
2959                summand_row[si] = i as u32;
2960            }
2961        }
2962        h.summand_row = summand_row;
2963
2964        // A summand's variable set (`all_vars`) equals its flat tape's,
2965        // so this is `con_tape_colors` inverted to color-major order —
2966        // the loop `eval_h` actually runs.
2967        let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
2968        for (si, s) in h.tape.summands.iter().enumerate() {
2969            let mut cs: Vec<u32> = s
2970                .all_vars
2971                .iter()
2972                .map(|&v| var_color[v])
2973                .filter(|&c| c != u32::MAX)
2974                .collect();
2975            cs.sort_unstable();
2976            cs.dedup();
2977            for c in cs {
2978                by_color[c as usize].push(si as u32);
2979            }
2980        }
2981        // Per-color prelude reach: the union of `prelude_reach` over the
2982        // color's summands, ascending. A union of operand-closed
2983        // ascending sets is itself operand-closed and ascending, which is
2984        // exactly what the two prelude sweeps require. Deduped with an
2985        // epoch-tagged buffer so the build costs
2986        // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
2987        // it saves — rather than `n_colors × |prelude|`.
2988        let np = h.tape.n_prelude_ops();
2989        let mut seen: Vec<u32> = vec![0; np];
2990        let mut epoch: u32 = 0;
2991        let mut reach: Vec<u32> = Vec::new();
2992        let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
2993        for list in &by_color {
2994            reach_off.push(reach.len());
2995            epoch += 1;
2996            let start = reach.len();
2997            for &si in list {
2998                for &p in &h.tape.summands[si as usize].prelude_reach {
2999                    if seen[p] != epoch {
3000                        seen[p] = epoch;
3001                        reach.push(p as u32);
3002                    }
3003                }
3004            }
3005            reach[start..].sort_unstable();
3006        }
3007        reach_off.push(reach.len());
3008        h.hess_color_reach = reach;
3009        h.hess_color_reach_off = reach_off;
3010
3011        h.hess_color_summands = by_color;
3012        h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
3013        h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
3014        h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
3015        h.local_dot = vec![0.0; h.tape.max_summand_ops()];
3016        h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
3017    }
3018
3019    ColorTables {
3020        var_color,
3021        n_colors,
3022        peeled_cols: peeled
3023            .iter()
3024            .enumerate()
3025            .filter(|(_, p)| **p)
3026            .map(|(j, _)| j as u32)
3027            .collect(),
3028        seeds,
3029        decoding,
3030        obj_tape_colors,
3031        con_tape_colors,
3032    }
3033}
3034
3035/// The color-dependent half of an [`NlTnlp`], as built by
3036/// [`build_color_tables`].
3037struct ColorTables {
3038    var_color: Vec<u32>,
3039    n_colors: usize,
3040    /// Columns given a singleton color and dropped from the conflict
3041    /// structure. Small by construction (`MAX_PEELED_COLS`).
3042    peeled_cols: Vec<u32>,
3043    seeds: Vec<Vec<f64>>,
3044    decoding: Vec<Vec<ColorWrite>>,
3045    obj_tape_colors: Vec<Vec<u32>>,
3046    con_tape_colors: Vec<Vec<Vec<u32>>>,
3047}
3048
3049impl NlTnlp {
3050    /// Build the TNLP, panicking if AMPL external-function resolution fails.
3051    ///
3052    /// Kept for the many infallible call sites (CLI, tests) that operate on
3053    /// `.nl` models known to need no external libraries. Surfaces that can be
3054    /// handed an arbitrary user model — notably the Python `read_nl` binding —
3055    /// must call [`Self::try_new`] instead so a missing `$AMPLFUNC` library
3056    /// becomes a catchable error rather than an uncatchable panic across the
3057    /// pyo3 boundary.
3058    pub fn new(prob: NlProblem) -> Self {
3059        Self::try_new(prob)
3060            .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
3061    }
3062
3063    /// Build the TNLP, returning an error (instead of panicking) when AMPL
3064    /// imported functions named by the model can't be resolved — e.g.
3065    /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
3066    /// referenced function id isn't registered by any loaded library.
3067    pub fn try_new(prob: NlProblem) -> Result<Self, String> {
3068        // Resolve any AMPL imported (external) functions. Walk every
3069        // nonlinear expression to collect the funcall ids actually
3070        // referenced; load the libraries named in $AMPLFUNC and bind
3071        // each id to its (library, registered-name) pair so the tape
3072        // builder can emit live `TapeOp::Funcall` ops.
3073        let mut referenced: BTreeSet<usize> = BTreeSet::new();
3074        super::nl_external::collect_funcall_ids(&prob.obj_nonlinear, &mut referenced);
3075        for c in &prob.con_nonlinear {
3076            super::nl_external::collect_funcall_ids(c, &mut referenced);
3077        }
3078        let resolver = if referenced.is_empty() {
3079            super::nl_external::ExternalResolver::default()
3080        } else {
3081            super::nl_external::ExternalResolver::build_for_problem(
3082                &prob.imported_funcs,
3083                &referenced,
3084            )?
3085        };
3086
3087        // Flatten objective and each constraint into independent
3088        // summands. Each summand becomes its own `Tape` (CSE bodies
3089        // are deduplicated within a tape via Rc identity in
3090        // `Tape::build`; bodies shared across summands are
3091        // duplicated, which we accept as a simplicity tradeoff).
3092        let obj_summands = split_top_sums(&prob.obj_nonlinear);
3093        let obj_tapes: Vec<Tape> = obj_summands
3094            .iter()
3095            .map(|e| Tape::build_with_externals(e, &resolver))
3096            .collect();
3097
3098        let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
3099        let mut con_roots: Vec<Expr> = Vec::new();
3100        let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
3101        for k in 0..prob.m {
3102            let summands = split_top_sums(&prob.con_nonlinear[k]);
3103            row_start.push(con_roots.len());
3104            con_tapes.push(
3105                summands
3106                    .iter()
3107                    .map(|e| Tape::build_with_externals(e, &resolver))
3108                    .collect(),
3109            );
3110            // Move (not clone) the split summands into the root list: their
3111            // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
3112            // sharing on `Arc` pointer identity, so the roots must be the
3113            // same allocations the parse produced.
3114            con_roots.extend(summands);
3115        }
3116        row_start.push(con_roots.len());
3117
3118        // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
3119        // building only when some CSE body is actually referenced from two
3120        // or more summands — otherwise the prelude comes out empty and the
3121        // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
3122        // gates the opcodes `build_multi` would panic on.
3123        // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
3124        // whole constraint block. Diagnostic only: it is how the
3125        // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
3126        // on a real model, and how a suspected hybrid-path bug is bisected
3127        // against a reference that computes the same derivatives a
3128        // different way.
3129        let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
3130            None
3131        } else if hybrid_supported(&con_roots) {
3132            let tape = HybridTape::build_multi(&con_roots);
3133            (tape.n_prelude_ops() > 0).then(|| {
3134                let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
3135                let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
3136                // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
3137                // regardless of the op ratio. Diagnostic only — it is how the
3138                // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
3139                // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
3140                // models that sit below the gate.
3141                let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
3142                ConHybrid {
3143                    prelude_vals: vec![0.0; tape.n_prelude_ops()],
3144                    local_vals: vec![0.0; tape.max_summand_ops()],
3145                    local_adj: vec![0.0; tape.max_summand_ops()],
3146                    prelude_adj: vec![0.0; tape.n_prelude_ops()],
3147                    use_for_jac: flat_ops as f64
3148                        >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
3149                    use_for_hess: force_hess
3150                        || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
3151                    local_vals_all: Vec::new(),
3152                    local_off: Vec::new(),
3153                    summand_row: Vec::new(),
3154                    hess_color_summands: Vec::new(),
3155                    hess_color_reach: Vec::new(),
3156                    hess_color_reach_off: Vec::new(),
3157                    prelude_dot: Vec::new(),
3158                    hess_prelude_adj: Vec::new(),
3159                    prelude_adj_dot: Vec::new(),
3160                    local_dot: Vec::new(),
3161                    local_adj_dot: Vec::new(),
3162                    row_start,
3163                    tape,
3164                }
3165            })
3166        } else {
3167            None
3168        };
3169        drop(con_roots);
3170
3171        // Hessian-of-Lagrangian sparsity: union of each tape's own
3172        // structural Hessian sparsity.
3173        // One flat `Vec`, sorted and deduped once, rather than a global
3174        // `BTreeSet` fed a single insert at a time across every summand
3175        // in the model: sort+dedup walks contiguous memory where the tree
3176        // chased a pointer and allocated a node per entry. The result is
3177        // exactly the ascending order the rest of this function wants, so
3178        // it doubles as `lower_pairs` instead of being copied into it.
3179        let mut lower_pairs: Vec<(usize, usize)> = Vec::new();
3180        for t in &obj_tapes {
3181            lower_pairs.extend(t.hessian_sparsity());
3182        }
3183        for row in &con_tapes {
3184            for t in row {
3185                lower_pairs.extend(t.hessian_sparsity());
3186            }
3187        }
3188        lower_pairs.sort_unstable();
3189        lower_pairs.dedup();
3190
3191        let mut h_irow = Vec::with_capacity(lower_pairs.len());
3192        let mut h_jcol = Vec::with_capacity(lower_pairs.len());
3193        for &(hi, lo) in &lower_pairs {
3194            h_irow.push(hi as i32);
3195            h_jcol.push(lo as i32);
3196        }
3197
3198        // Hessian column coloring and everything keyed off it. The
3199        // chromatic number of the column-intersection graph bounds how
3200        // many directional Hessian-vector products we need per `eval_h`
3201        // call — typically O(stencil) for PDE-mesh problems.
3202        let ColorTables {
3203            var_color,
3204            n_colors,
3205            peeled_cols,
3206            seeds,
3207            decoding,
3208            obj_tape_colors,
3209            con_tape_colors,
3210        } = build_color_tables(
3211            prob.n,
3212            prob.m,
3213            &lower_pairs,
3214            &vec![false; prob.n],
3215            &obj_tapes,
3216            &con_tapes,
3217            con_hybrid.as_mut(),
3218        );
3219
3220        // Per-row Jacobian sparsity = union of tape vars plus
3221        // linear-segment vars.
3222        let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
3223        let mut jac_nnz = 0;
3224        for (i, row_tapes) in con_tapes.iter().enumerate() {
3225            let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
3226            for t in row_tapes {
3227                cols.extend(t.variables());
3228            }
3229            cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
3230            cols.sort_unstable();
3231            cols.dedup();
3232            cols.shrink_to_fit();
3233            jac_nnz += cols.len();
3234            jac_cols.push(cols);
3235        }
3236
3237        let mut max_tape_n: usize = 0;
3238        for t in &obj_tapes {
3239            max_tape_n = max_tape_n.max(t.ops.len());
3240        }
3241        for row in &con_tapes {
3242            for t in row {
3243                max_tape_n = max_tape_n.max(t.ops.len());
3244            }
3245        }
3246
3247        if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
3248            let n_obj = obj_tapes.len();
3249            let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
3250            let total = n_obj + n_con;
3251            let mut sum_ops: usize = 0;
3252            for t in &obj_tapes {
3253                sum_ops += t.ops.len();
3254            }
3255            for row in &con_tapes {
3256                for t in row {
3257                    sum_ops += t.ops.len();
3258                }
3259            }
3260            let t = total.max(1);
3261            let nnz_h = h_irow.len();
3262            let avg_decode =
3263                decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
3264            eprintln!(
3265                "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
3266                 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
3267                 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h}",
3268                sum_ops as f64 / t as f64,
3269            );
3270            // Flat vs shared-CSE op counts for the constraint block. The
3271            // ratio is how much duplicated CSE work `eval_g`'s hybrid path
3272            // avoids, and the ceiling on what routing the Jacobian /
3273            // Hessian through the same prelude could save.
3274            match &con_hybrid {
3275                Some(h) => {
3276                    let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
3277                    let prelude = h.tape.n_prelude_ops();
3278                    let local = h.tape.total_local_ops();
3279                    eprintln!(
3280                        "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
3281                         local_ops={local} shared_total={} flat/shared={:.2}x \
3282                         jac_gate={} hess_gate={}",
3283                        prelude + local,
3284                        flat as f64 / (prelude + local).max(1) as f64,
3285                        if h.use_for_jac { "on" } else { "off" },
3286                        if h.use_for_hess { "on" } else { "off" },
3287                    );
3288                }
3289                None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
3290            }
3291        }
3292
3293        let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
3294
3295        let mut me = Self {
3296            prob,
3297            obj_tapes,
3298            con_tapes,
3299            con_hybrid,
3300            h_irow,
3301            h_jcol,
3302            jac_cols,
3303            jac_nnz,
3304            seeds,
3305            decoding,
3306            obj_tape_colors,
3307            con_tape_colors,
3308            var_color,
3309            peeled_cols,
3310            final_x: None,
3311            final_obj: 0.0,
3312            final_lambda: None,
3313            final_z_l: None,
3314            final_z_u: None,
3315            scratch_row_grad: Vec::new(),
3316            vals_scratch: vec![0.0; max_tape_n],
3317            dot_scratch: vec![0.0; max_tape_n],
3318            adj_scratch: vec![0.0; max_tape_n],
3319            adj_dot_scratch: vec![0.0; max_tape_n],
3320            compressed,
3321            hvp_live: Vec::new(),
3322        };
3323        me.veto_ill_conditioned_peels();
3324        Ok(me)
3325    }
3326
3327    /// Un-peel any dense column whose own pass is too ill-scaled to read
3328    /// its small entries out of, and re-color if that changes anything.
3329    ///
3330    /// `greedy_hessian_coloring` picks the peel set from structure alone,
3331    /// which is the right call for the memory and the color count but
3332    /// blind to the one thing that can go wrong: an entry recovered from
3333    /// column `d`'s pass inherits that pass's roundoff floor, about
3334    /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
3335    /// well-scaled dense column loses nothing to that — the recovered
3336    /// entries come back bit-identical to the uncompressed reference on
3337    /// every peel-firing model in the benchmark corpus but one. A column
3338    /// spanning many orders of magnitude, though, hands its small entries
3339    /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
3340    /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
3341    /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
3342    /// survives that, but the multipliers come out of the KKT system the
3343    /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
3344    /// the solve stalls short of `Optimal` on a problem it used to
3345    /// certify.
3346    ///
3347    /// Nothing structural distinguishes the two cases, so measure it: one
3348    /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
3349    /// column's exact pass sitting in `compressed`, and a column whose
3350    /// worst recovered entry would lose more than
3351    /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
3352    /// one `eval_h` — at the peeled color count, so cheap — and only for
3353    /// the ~3% of models that peel anything at all.
3354    fn veto_ill_conditioned_peels(&mut self) {
3355        if self.peeled_cols.is_empty() {
3356            return;
3357        }
3358
3359        let mut values = vec![0.0; self.h_irow.len()];
3360        let lambda = vec![1.0; self.prob.m];
3361        let x0 = self.prob.x0.clone();
3362        if !self.eval_h(
3363            Some(&x0),
3364            true,
3365            1.0,
3366            Some(&lambda),
3367            true,
3368            SparsityRequest::Values {
3369                values: &mut values,
3370            },
3371        ) {
3372            return;
3373        }
3374
3375        let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
3376        // A column whose whole pass is negligible against the Hessian as a
3377        // whole cannot move the KKT system no matter how badly its own
3378        // entries are rounded, and columns that are identically zero at
3379        // `x0` would otherwise veto on a ratio of pure noise.
3380        let h_scale = self
3381            .compressed
3382            .iter()
3383            .flat_map(|c| c.iter())
3384            .fold(0.0f64, |a, &v| a.max(v.abs()));
3385        let mut peel_veto = vec![false; self.prob.n];
3386        let mut vetoed = 0usize;
3387        for &d in &self.peeled_cols {
3388            let c = self.var_color[d as usize];
3389            if c == u32::MAX {
3390                continue;
3391            }
3392            let pass = &self.compressed[c as usize];
3393            // The floor the pass was accumulated at, against the smallest
3394            // entry actually read out of it.
3395            let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
3396            // Entries at or below the pass's own roundoff floor carry no
3397            // information to lose: `eps * scale` is the noise the pass was
3398            // accumulated at, so such an entry is already indistinguishable
3399            // from zero whether or not the column is peeled. Including them
3400            // would divide by that noise -- `orthregd` holds entries of
3401            // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
3402            // 4e-16 against an uncompressed reference.
3403            let noise = f64::EPSILON * scale;
3404            let smallest = self.decoding[c as usize]
3405                .iter()
3406                .map(|w| pass[w.row as usize].abs())
3407                .filter(|v| *v > noise)
3408                .fold(f64::INFINITY, f64::min);
3409            if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
3410                continue;
3411            }
3412            if scale <= h_scale * f64::EPSILON {
3413                continue;
3414            }
3415            let rel_err = f64::EPSILON * scale / smallest;
3416            if dbg {
3417                eprintln!(
3418                    "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
3419                     min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
3420                    if rel_err > PEEL_MAX_REL_ERR {
3421                        " VETO"
3422                    } else {
3423                        ""
3424                    }
3425                );
3426            }
3427            if rel_err > PEEL_MAX_REL_ERR {
3428                peel_veto[d as usize] = true;
3429                vetoed += 1;
3430            }
3431        }
3432
3433        if vetoed == 0 {
3434            return;
3435        }
3436        if dbg {
3437            eprintln!(
3438                "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
3439                self.peeled_cols.len()
3440            );
3441        }
3442        self.recolor(&peel_veto);
3443    }
3444
3445    /// Rebuild the coloring and everything keyed off it, barring
3446    /// `peel_veto` from the peel set.
3447    fn recolor(&mut self, peel_veto: &[bool]) {
3448        let lower_pairs: Vec<(usize, usize)> = self
3449            .h_irow
3450            .iter()
3451            .zip(&self.h_jcol)
3452            .map(|(&i, &j)| (i as usize, j as usize))
3453            .collect();
3454        let ColorTables {
3455            var_color,
3456            n_colors,
3457            peeled_cols,
3458            seeds,
3459            decoding,
3460            obj_tape_colors,
3461            con_tape_colors,
3462        } = build_color_tables(
3463            self.prob.n,
3464            self.prob.m,
3465            &lower_pairs,
3466            peel_veto,
3467            &self.obj_tapes,
3468            &self.con_tapes,
3469            self.con_hybrid.as_mut(),
3470        );
3471        self.var_color = var_color;
3472        self.peeled_cols = peeled_cols;
3473        self.seeds = seeds;
3474        self.decoding = decoding;
3475        self.obj_tape_colors = obj_tape_colors;
3476        self.con_tape_colors = con_tape_colors;
3477        self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
3478    }
3479
3480    pub fn final_x(&self) -> Option<&[Number]> {
3481        self.final_x.as_deref()
3482    }
3483
3484    pub fn final_obj(&self) -> Number {
3485        self.final_obj
3486    }
3487
3488    /// Converged constraint multipliers from the last solve, in original
3489    /// `.nl` row order. `None` before a solve finishes. See
3490    /// [`Self::final_x`] for the primal counterpart.
3491    pub fn final_lambda(&self) -> Option<&[Number]> {
3492        self.final_lambda.as_deref()
3493    }
3494
3495    /// Converged lower / upper bound multipliers from the last solve, in
3496    /// original `.nl` variable order and Ipopt's internal convention (both
3497    /// `>= 0`). `None` before a solve finishes.
3498    pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
3499        Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
3500    }
3501
3502    /// The parsed problem this TNLP evaluates (bounds, starting point,
3503    /// names, suffixes). Read-only; per-instance overrides go through
3504    /// [`Self::variant`].
3505    pub fn problem(&self) -> &NlProblem {
3506        &self.prob
3507    }
3508
3509    /// Mutable access to that same problem, for a caller that owns this
3510    /// TNLP outright.
3511    ///
3512    /// The tapes were built from the expressions in [`Self::problem`] and
3513    /// are not rebuilt, so editing an expression here does **not** change
3514    /// what this TNLP evaluates. It exists for teardown: the Python
3515    /// binding takes the expression trees out through here so a deeply
3516    /// nested one is dropped on a stack chosen for it rather than
3517    /// recursively on whatever thread collected the object (pounce#472).
3518    pub fn problem_mut(&mut self) -> &mut NlProblem {
3519        &mut self.prob
3520    }
3521
3522    /// Hessian-vector product of the Lagrangian:
3523    /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
3524    ///
3525    /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
3526    /// [`Tape::hessian_directional`] pass *per color* and then decodes the
3527    /// compressed columns into the sparse lower triangle; here the seed is
3528    /// the caller's `v` directly, so it is a single forward-over-reverse
3529    /// pass per tape — O(tape ops), independent of `n` and of the coloring's
3530    /// chromatic number. That is what makes it usable on models where
3531    /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
3532    /// truncated-CG step only ever needs `∇²L · v`.
3533    ///
3534    /// Sign convention matches `eval_h` and the rest of this evaluator: a
3535    /// `maximize` model's objective is negated so the returned operator is
3536    /// the one that minimizing solves. `lambda` is `None` for the objective
3537    /// block alone.
3538    ///
3539    /// `out` is overwritten (not accumulated into). Errors on any length
3540    /// mismatch rather than panicking, since the Python binding hands this
3541    /// arbitrary user arrays.
3542    pub fn hessian_vector_product(
3543        &mut self,
3544        x: &[Number],
3545        v: &[Number],
3546        obj_factor: Number,
3547        lambda: Option<&[Number]>,
3548        out: &mut [Number],
3549    ) -> Result<(), String> {
3550        self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
3551    }
3552
3553    /// Block form of [`Self::hessian_vector_product`]: `k` directions at
3554    /// once, `out[:, c] = ∇²L · v[:, c]`.
3555    ///
3556    /// `v` and `out` are `n × k` in **column-major** order — direction `c`
3557    /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
3558    ///
3559    /// Worth having as its own entry point rather than a loop over the
3560    /// single-vector call: the forward sweep depends only on `x`, so a block
3561    /// runs it *once per tape* and reuses `vals` across all `k` directions,
3562    /// where `k` separate calls would redo it `k` times. Only the
3563    /// forward-tangent + reverse-over-tangent passes are per-direction. That
3564    /// is the shape a block-Krylov solve, a directional-derivative probe, or
3565    /// a densify-the-Hessian loop wants.
3566    ///
3567    /// An all-zero direction is skipped, so passing a sparse block whose
3568    /// columns are mostly empty costs only the columns that carry signal.
3569    /// (The sparsity that dominates is the model's own: each tape touches
3570    /// only its own variables, and `hessian_directional` is O(tape ops), not
3571    /// O(n).)
3572    pub fn hessian_vector_products(
3573        &mut self,
3574        x: &[Number],
3575        v: &[Number],
3576        k: usize,
3577        obj_factor: Number,
3578        lambda: Option<&[Number]>,
3579        out: &mut [Number],
3580    ) -> Result<(), String> {
3581        let (n, m) = (self.prob.n, self.prob.m);
3582        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
3583            if got == want {
3584                Ok(())
3585            } else {
3586                Err(format!(
3587                    "hessian_vector_product: {name} has length {got}, expected {want}"
3588                ))
3589            }
3590        };
3591        check("x", x.len(), n)?;
3592        check("v", v.len(), n * k)?;
3593        check("out", out.len(), n * k)?;
3594        if let Some(lam) = lambda {
3595            check("lambda", lam.len(), m)?;
3596        }
3597
3598        out.fill(0.0);
3599        if k == 0 || n == 0 {
3600            return Ok(());
3601        }
3602
3603        // Which directions carry signal. Computed once, not per (tape,
3604        // direction) pair — with many small summand tapes the scan would
3605        // otherwise dominate the work it is meant to save. Reuses the
3606        // persistent mask so a Krylov loop allocates nothing per iteration.
3607        self.hvp_live.clear();
3608        self.hvp_live
3609            .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
3610        if !self.hvp_live.iter().any(|&l| l) {
3611            return Ok(());
3612        }
3613
3614        let obj_seed = if self.prob.minimize {
3615            obj_factor
3616        } else {
3617            -obj_factor
3618        };
3619        if obj_seed != 0.0 {
3620            for t in &self.obj_tapes {
3621                if t.ops.is_empty() {
3622                    continue;
3623                }
3624                // Once per tape, not once per direction — the whole point
3625                // of the block form.
3626                t.forward_into(x, &mut self.vals_scratch);
3627                for (c, out_col) in out.chunks_mut(n).enumerate() {
3628                    if !self.hvp_live[c] {
3629                        continue;
3630                    }
3631                    t.hessian_directional(
3632                        &self.vals_scratch,
3633                        &v[c * n..(c + 1) * n],
3634                        obj_seed,
3635                        out_col,
3636                        &mut self.dot_scratch,
3637                        &mut self.adj_scratch,
3638                        &mut self.adj_dot_scratch,
3639                    );
3640                }
3641            }
3642        }
3643
3644        if let Some(lam) = lambda {
3645            // `lam.len() == m` was checked above, so `con_tapes[k]` is in
3646            // range for every k.
3647            for (i, &w) in lam.iter().enumerate() {
3648                if w == 0.0 {
3649                    continue;
3650                }
3651                for t in &self.con_tapes[i] {
3652                    if t.ops.is_empty() {
3653                        continue;
3654                    }
3655                    t.forward_into(x, &mut self.vals_scratch);
3656                    for (c, out_col) in out.chunks_mut(n).enumerate() {
3657                        if !self.hvp_live[c] {
3658                            continue;
3659                        }
3660                        t.hessian_directional(
3661                            &self.vals_scratch,
3662                            &v[c * n..(c + 1) * n],
3663                            w,
3664                            out_col,
3665                            &mut self.dot_scratch,
3666                            &mut self.adj_scratch,
3667                            &mut self.adj_dot_scratch,
3668                        );
3669                    }
3670                }
3671            }
3672        }
3673
3674        Ok(())
3675    }
3676
3677    /// Clone this TNLP with per-instance overrides applied — the
3678    /// "one structure, many bound / starting-point variations" case of
3679    /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
3680    /// coloring are reused via `Clone` (they depend only on the model
3681    /// structure, which a variation cannot change); only the values in
3682    /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
3683    /// are replaced. Any stale `final_x` from a previous solve of
3684    /// `self` is cleared on the clone.
3685    ///
3686    /// Errors when an override's length does not match the model
3687    /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
3688    pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
3689        let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
3690            if got == want {
3691                Ok(())
3692            } else {
3693                Err(format!(
3694                    "NlVariation.{name} has length {got}, expected {want}"
3695                ))
3696            }
3697        };
3698        let mut out = self.clone();
3699        out.final_x = None;
3700        out.final_obj = 0.0;
3701        out.final_lambda = None;
3702        out.final_z_l = None;
3703        out.final_z_u = None;
3704        if let Some(x0) = &v.x0 {
3705            check("x0", x0.len(), self.prob.n)?;
3706            out.prob.x0.clone_from(x0);
3707        }
3708        if let Some(x_l) = &v.x_l {
3709            check("x_l", x_l.len(), self.prob.n)?;
3710            out.prob.x_l.clone_from(x_l);
3711        }
3712        if let Some(x_u) = &v.x_u {
3713            check("x_u", x_u.len(), self.prob.n)?;
3714            out.prob.x_u.clone_from(x_u);
3715        }
3716        if let Some(g_l) = &v.g_l {
3717            check("g_l", g_l.len(), self.prob.m)?;
3718            out.prob.g_l.clone_from(g_l);
3719        }
3720        if let Some(g_u) = &v.g_u {
3721            check("g_u", g_u.len(), self.prob.m)?;
3722            out.prob.g_u.clone_from(g_u);
3723        }
3724        Ok(out)
3725    }
3726
3727    /// Build one [`NlTnlp`] per variation, sharing this instance's
3728    /// structure (see [`Self::variant`]). Returns instances in input
3729    /// order; errors on the first length-mismatched variation.
3730    pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
3731        vs.iter().map(|v| self.variant(v)).collect()
3732    }
3733}
3734
3735/// Per-instance overrides for building a family of related NLP
3736/// instances from one parsed `.nl` model (pounce#126): same structure
3737/// and tapes, different starting point and/or bounds — parametric
3738/// sweeps, multi-start, or branch-and-bound node relaxations where
3739/// each node only tightens variable bounds. `None` keeps the base
3740/// model's value.
3741#[derive(Debug, Clone, Default)]
3742pub struct NlVariation {
3743    pub x0: Option<Vec<Number>>,
3744    pub x_l: Option<Vec<Number>>,
3745    pub x_u: Option<Vec<Number>>,
3746    pub g_l: Option<Vec<Number>>,
3747    pub g_u: Option<Vec<Number>>,
3748}
3749
3750impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
3751    /// Per-`.nl`-row constraint expression tape, with the linear
3752    /// part folded in. Returns `None` for constraints that contribute
3753    /// neither a nonlinear expression nor any linear coefficients
3754    /// (so FBBT skips them — there's nothing to tighten).
3755    fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
3756        let nonlinear = self.prob.con_nonlinear.get(i)?;
3757        let linear = self
3758            .prob
3759            .con_linear
3760            .get(i)
3761            .map(|v| v.as_slice())
3762            .unwrap_or(&[]);
3763        crate::nl_fbbt_translate::translate_constraint(nonlinear, linear)
3764    }
3765
3766    /// Variable name from the sibling `.col` file, if one was loaded.
3767    /// Index is original `.nl` column order.
3768    fn variable_name(&self, i: usize) -> Option<&str> {
3769        self.prob.var_names.get(i).map(String::as_str)
3770    }
3771
3772    /// Constraint name from the sibling `.row` file, if one was loaded.
3773    /// Index is original `.nl` row order.
3774    fn constraint_name(&self, i: usize) -> Option<&str> {
3775        self.prob.con_names.get(i).map(String::as_str)
3776    }
3777}
3778
3779impl TNLP for NlTnlp {
3780    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
3781        Some(NlpInfo {
3782            n: self.prob.n as Index,
3783            m: self.prob.m as Index,
3784            nnz_jac_g: self.jac_nnz as Index,
3785            nnz_h_lag: self.h_irow.len() as Index,
3786            index_style: IndexStyle::C,
3787        })
3788    }
3789
3790    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
3791        b.x_l.copy_from_slice(&self.prob.x_l);
3792        b.x_u.copy_from_slice(&self.prob.x_u);
3793        if !self.prob.g_l.is_empty() {
3794            b.g_l.copy_from_slice(&self.prob.g_l);
3795            b.g_u.copy_from_slice(&self.prob.g_u);
3796        }
3797        true
3798    }
3799
3800    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
3801        sp.x.copy_from_slice(&self.prob.x0);
3802        // The `.nl` `d` segment supplies initial constraint multipliers
3803        // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
3804        // the engine when `warm_start_init_point yes` — by handing them
3805        // back; `OrigIpoptNlp::get_starting_point` then compresses them into
3806        // the algorithm-side y_c / y_d. Without this the warm start silently
3807        // began from zero multipliers, discarding the parsed duals. (Code
3808        // review 2026-06 item M19.) The `.nl` `d` segment carries no bound
3809        // multipliers, so `z_l`/`z_u` are left to the engine's defaults.
3810        if sp.init_lambda {
3811            sp.lambda.copy_from_slice(&self.prob.lambda0);
3812        }
3813        true
3814    }
3815
3816    /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
3817    /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
3818    /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
3819    /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
3820    /// writes into. Before gh#483 nothing implemented this callback for
3821    /// `.nl` input, so a tagged model reached the solver with the option
3822    /// accepted and *no* scaling applied, silently.
3823    ///
3824    /// Returns `false` (engine falls back to no scaling) when the file
3825    /// declares no `scaling_factor` suffix at all — the same "user
3826    /// supplied nothing" answer as the default `TNLP` impl.
3827    ///
3828    /// AMPL suffix vectors default to **0** for components the model did
3829    /// not tag, and 0 is not a usable scale factor. A zero entry is
3830    /// therefore read as "not tagged" and becomes 1.0, which is what
3831    /// "unlisted components are unscaled" means. Per-variable factors
3832    /// are passed straight through: `OrigIpoptNlp` does not model them
3833    /// and refuses the solve with a message rather than dropping them.
3834    fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
3835        const NAME: &str = "scaling_factor";
3836        let sfx = &self.prob.suffixes;
3837        let obj = sfx.obj_real.get(NAME);
3838        let var = sfx.var_real.get(NAME);
3839        let con = sfx.con_real.get(NAME);
3840        if obj.is_none() && var.is_none() && con.is_none() {
3841            return false;
3842        }
3843        // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
3844        // are parsed and ignored), so its entry is the objective scale.
3845        *req.obj_scaling = obj
3846            .and_then(|v| v.first().copied())
3847            .filter(|&s| s != 0.0)
3848            .unwrap_or(1.0);
3849        *req.use_x_scaling = match var {
3850            Some(v) if v.len() == req.x_scaling.len() => {
3851                for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
3852                    *slot = if s == 0.0 { 1.0 } else { s };
3853                }
3854                true
3855            }
3856            _ => false,
3857        };
3858        *req.use_g_scaling = match con {
3859            Some(g) if g.len() == req.g_scaling.len() => {
3860                for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
3861                    *slot = if s == 0.0 { 1.0 } else { s };
3862                }
3863                true
3864            }
3865            _ => false,
3866        };
3867        true
3868    }
3869
3870    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
3871        // Reuse the shared forward-value arena (sized to `max_tape_n`) so
3872        // each summand sweep allocates nothing — see `Tape::eval_into`.
3873        let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
3874        let mut nl: Number = 0.0;
3875        for t in obj_tapes {
3876            nl += t.eval_into(x, vals);
3877        }
3878        let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
3879        let v = self.prob.obj_constant + nl + lin;
3880        let signed = if self.prob.minimize { v } else { -v };
3881        Some(signed)
3882    }
3883
3884    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
3885        grad.fill(0.0);
3886        // Reuse the forward-value / adjoint scratch arenas (sized to
3887        // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
3888        // nothing — see `Tape::gradient_seed_into` (M18).
3889        for t in &self.obj_tapes {
3890            t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
3891        }
3892        for (i, c) in &self.prob.obj_linear {
3893            grad[*i] += c;
3894        }
3895        if !self.prob.minimize {
3896            for g in grad.iter_mut() {
3897                *g = -*g;
3898            }
3899        }
3900        true
3901    }
3902
3903    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
3904        // Constraint values are the line search's inner loop: on a
3905        // constraint-heavy model (m >> n) this runs ~10x per iteration over
3906        // every summand tape in the problem. Reuse the shared forward-value
3907        // arena so the sweep allocates nothing — the per-summand `Vec` the
3908        // allocating `Tape::eval` used to build was ~20% of `eval_g` on
3909        // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
3910        // `Tape::eval_into`.
3911        let m = self.prob.m;
3912        let con_linear = &self.prob.con_linear;
3913        if let Some(h) = &mut self.con_hybrid {
3914            // Shared CSE bodies once for the whole constraint block, then one
3915            // local sweep per summand (pounce#476).
3916            let ConHybrid {
3917                tape,
3918                row_start,
3919                prelude_vals,
3920                local_vals,
3921                ..
3922            } = h;
3923            tape.forward_prelude(x, prelude_vals);
3924            for i in 0..m {
3925                let mut nl: Number = 0.0;
3926                for s in &tape.summands[row_start[i]..row_start[i + 1]] {
3927                    tape.forward_summand(s, x, prelude_vals, local_vals);
3928                    nl += tape.root_value(s, local_vals);
3929                }
3930                let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
3931                g[i] = nl + lin;
3932            }
3933            return true;
3934        }
3935        let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
3936        for i in 0..m {
3937            let mut nl: Number = 0.0;
3938            for t in &con_tapes[i] {
3939                nl += t.eval_into(x, vals);
3940            }
3941            let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
3942            g[i] = nl + lin;
3943        }
3944        true
3945    }
3946
3947    fn eval_jac_g(
3948        &mut self,
3949        x: Option<&[Number]>,
3950        _new_x: bool,
3951        mode: SparsityRequest<'_>,
3952    ) -> bool {
3953        match mode {
3954            SparsityRequest::Structure { irow, jcol } => {
3955                let mut k = 0;
3956                for i in 0..self.prob.m {
3957                    for &j in &self.jac_cols[i] {
3958                        irow[k] = i as Index;
3959                        jcol[k] = j as Index;
3960                        k += 1;
3961                    }
3962                }
3963                true
3964            }
3965            SparsityRequest::Values { values } => {
3966                let n = self.prob.n;
3967                if self.scratch_row_grad.len() < n {
3968                    self.scratch_row_grad.resize(n, 0.0);
3969                }
3970                let Self {
3971                    prob,
3972                    con_tapes,
3973                    con_hybrid,
3974                    jac_cols,
3975                    scratch_row_grad,
3976                    vals_scratch,
3977                    adj_scratch,
3978                    ..
3979                } = self;
3980                let xs = x.unwrap_or(&prob.x0);
3981                let mut k = 0;
3982                // Shared-CSE path: the forward sweep over the CSE bodies runs
3983                // once for the whole constraint block instead of once per
3984                // referencing summand. The reverse sweep cannot be shared —
3985                // each row needs its own gradient — so a summand still walks
3986                // its own `prelude_reach` backwards.
3987                if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
3988                    let ConHybrid {
3989                        tape,
3990                        row_start,
3991                        prelude_vals,
3992                        local_vals,
3993                        local_adj,
3994                        prelude_adj,
3995                        ..
3996                    } = h;
3997                    tape.forward_prelude(xs, prelude_vals);
3998                    for i in 0..prob.m {
3999                        for &j in &jac_cols[i] {
4000                            scratch_row_grad[j] = 0.0;
4001                        }
4002                        for s in &tape.summands[row_start[i]..row_start[i + 1]] {
4003                            tape.forward_summand(s, xs, prelude_vals, local_vals);
4004                            tape.gradient_summand(
4005                                s,
4006                                prelude_vals,
4007                                local_vals,
4008                                1.0,
4009                                scratch_row_grad,
4010                                local_adj,
4011                                prelude_adj,
4012                            );
4013                        }
4014                        for &(v, c) in &prob.con_linear[i] {
4015                            scratch_row_grad[v] += c;
4016                        }
4017                        for &j in &jac_cols[i] {
4018                            values[k] = scratch_row_grad[j];
4019                            k += 1;
4020                        }
4021                    }
4022                    return true;
4023                }
4024                for i in 0..prob.m {
4025                    for &j in &jac_cols[i] {
4026                        scratch_row_grad[j] = 0.0;
4027                    }
4028                    for t in &con_tapes[i] {
4029                        // Allocation-free reverse-AD per summand tape (M18):
4030                        // reuse the shared forward/adjoint scratch arenas.
4031                        t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
4032                    }
4033                    for &(v, c) in &prob.con_linear[i] {
4034                        scratch_row_grad[v] += c;
4035                    }
4036                    for &j in &jac_cols[i] {
4037                        values[k] = scratch_row_grad[j];
4038                        k += 1;
4039                    }
4040                }
4041                true
4042            }
4043        }
4044    }
4045
4046    fn eval_h(
4047        &mut self,
4048        x: Option<&[Number]>,
4049        _new_x: bool,
4050        obj_factor: Number,
4051        lambda: Option<&[Number]>,
4052        _new_lambda: bool,
4053        mode: SparsityRequest<'_>,
4054    ) -> bool {
4055        match mode {
4056            SparsityRequest::Structure { irow, jcol } => {
4057                irow.copy_from_slice(&self.h_irow);
4058                jcol.copy_from_slice(&self.h_jcol);
4059                true
4060            }
4061            SparsityRequest::Values { values } => {
4062                let x = x.unwrap_or(&self.prob.x0);
4063                values.fill(0.0);
4064
4065                let obj_seed = if self.prob.minimize {
4066                    obj_factor
4067                } else {
4068                    -obj_factor
4069                };
4070                // Coloring path. For each (tape, weight) we do
4071                // one forward pass into `vals_scratch`, then one
4072                // forward-tangent+reverse-over-tangent per color
4073                // touched by that tape. Each pass accumulates a
4074                // weighted contribution of (H_tape · seed_c) into
4075                // `compressed[c]`. After all tapes done, we
4076                // decode each color's compressed vector into the
4077                // sparse `values` array.
4078                for buf in &mut self.compressed {
4079                    buf.fill(0.0);
4080                }
4081
4082                if obj_seed != 0.0 {
4083                    for (ti, t) in self.obj_tapes.iter().enumerate() {
4084                        if t.ops.is_empty() {
4085                            continue;
4086                        }
4087                        t.forward_into(x, &mut self.vals_scratch);
4088                        for &c in &self.obj_tape_colors[ti] {
4089                            t.hessian_directional(
4090                                &self.vals_scratch,
4091                                &self.seeds[c as usize],
4092                                obj_seed,
4093                                &mut self.compressed[c as usize],
4094                                &mut self.dot_scratch,
4095                                &mut self.adj_scratch,
4096                                &mut self.adj_dot_scratch,
4097                            );
4098                        }
4099                    }
4100                }
4101
4102                match (lambda, self.con_hybrid.as_mut()) {
4103                    // Shared-CSE path (issue #557). Per color the prelude's
4104                    // second-order work runs ONCE for the whole constraint
4105                    // block: one forward tangent, then — because
4106                    // reverse-over-tangent is linear in its adjoint seeds —
4107                    // one unit-weight reverse sweep over the λ-weighted
4108                    // adjoints accumulated by every summand of that color.
4109                    // The flat path below repeats both sweeps over the
4110                    // inlined CSE body once per referencing summand.
4111                    (Some(lam), Some(h)) if h.use_for_hess => {
4112                        let ConHybrid {
4113                            tape,
4114                            prelude_vals,
4115                            local_vals_all,
4116                            local_off,
4117                            summand_row,
4118                            hess_color_summands,
4119                            hess_color_reach,
4120                            hess_color_reach_off,
4121                            prelude_dot,
4122                            hess_prelude_adj,
4123                            prelude_adj_dot,
4124                            local_dot,
4125                            local_adj,
4126                            local_adj_dot,
4127                            ..
4128                        } = h;
4129                        // Forward once (values are color-independent):
4130                        // prelude for the block, then each summand of a row
4131                        // with a live multiplier into its packed slice.
4132                        tape.forward_prelude(x, prelude_vals);
4133                        for (si, s) in tape.summands.iter().enumerate() {
4134                            if lam[summand_row[si] as usize] == 0.0 {
4135                                continue;
4136                            }
4137                            tape.forward_summand(
4138                                s,
4139                                x,
4140                                prelude_vals,
4141                                &mut local_vals_all[local_off[si]..local_off[si + 1]],
4142                            );
4143                        }
4144                        for (c, list) in hess_color_summands.iter().enumerate() {
4145                            if !list
4146                                .iter()
4147                                .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
4148                            {
4149                                continue;
4150                            }
4151                            let seed = &self.seeds[c];
4152                            let out = &mut self.compressed[c];
4153                            let creach = &hess_color_reach
4154                                [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
4155                            tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
4156                            for &si in list {
4157                                let si = si as usize;
4158                                let w = lam[summand_row[si] as usize];
4159                                if w == 0.0 {
4160                                    continue;
4161                                }
4162                                tape.hessian_summand_directional(
4163                                    &tape.summands[si],
4164                                    &local_vals_all[local_off[si]..local_off[si + 1]],
4165                                    prelude_dot,
4166                                    seed,
4167                                    w,
4168                                    out,
4169                                    local_dot,
4170                                    local_adj,
4171                                    local_adj_dot,
4172                                    hess_prelude_adj,
4173                                    prelude_adj_dot,
4174                                );
4175                            }
4176                            tape.prelude_reverse_directional(
4177                                prelude_vals,
4178                                prelude_dot,
4179                                creach,
4180                                out,
4181                                hess_prelude_adj,
4182                                prelude_adj_dot,
4183                            );
4184                        }
4185                    }
4186                    (Some(lam), _) => {
4187                        for k in 0..self.prob.m {
4188                            let w = lam[k];
4189                            if w == 0.0 {
4190                                continue;
4191                            }
4192                            for (ti, t) in self.con_tapes[k].iter().enumerate() {
4193                                if t.ops.is_empty() {
4194                                    continue;
4195                                }
4196                                t.forward_into(x, &mut self.vals_scratch);
4197                                for &c in &self.con_tape_colors[k][ti] {
4198                                    t.hessian_directional(
4199                                        &self.vals_scratch,
4200                                        &self.seeds[c as usize],
4201                                        w,
4202                                        &mut self.compressed[c as usize],
4203                                        &mut self.dot_scratch,
4204                                        &mut self.adj_scratch,
4205                                        &mut self.adj_dot_scratch,
4206                                    );
4207                                }
4208                            }
4209                        }
4210                    }
4211                    (None, _) => {}
4212                }
4213
4214                // Decode each color's compressed Hessian-vector
4215                // result into the lower-triangle `values` array.
4216                for (c, table) in self.decoding.iter().enumerate() {
4217                    let comp = &self.compressed[c];
4218                    for w in table {
4219                        values[w.hess_idx as usize] += comp[w.row as usize];
4220                    }
4221                }
4222                true
4223            }
4224        }
4225    }
4226
4227    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
4228        self.final_x = Some(sol.x.to_vec());
4229        self.final_obj = sol.obj_value;
4230        self.final_lambda = Some(sol.lambda.to_vec());
4231        self.final_z_l = Some(sol.z_l.to_vec());
4232        self.final_z_u = Some(sol.z_u.to_vec());
4233    }
4234
4235    /// Publish the `.col` / `.row` names (captured at load time) under the
4236    /// conventional `idx_names` metadata key, in original `.nl` order. The
4237    /// adapter permutes these into split space (see
4238    /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
4239    /// near-singular Jacobian row as the `mass_balance` equation rather
4240    /// than "row 3" — the model-vs-index gap Lee et al. (2024,
4241    /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
4242    /// model debugging. Declines (returns false) when the model shipped no
4243    /// name files so callers fall back to index labels.
4244    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
4245        let mut any = false;
4246        if !self.prob.var_names.is_empty() {
4247            var.strings
4248                .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
4249            any = true;
4250        }
4251        if !self.prob.con_names.is_empty() {
4252            con.strings
4253                .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
4254            any = true;
4255        }
4256        any
4257    }
4258
4259    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
4260        // A row is linear iff its nonlinear-part expression is the
4261        // identity zero — either left over from initial allocation ("no
4262        // `C<idx>` segment touched this row") or installed by the
4263        // constant-row-body fold in `parse_nl_text`, which shifts a
4264        // variable-free `C<idx>` body into the row bounds precisely so
4265        // that this test is a genuine linearity test and not just an
4266        // identity check (`gh #492`).
4267        for (i, t) in types.iter_mut().enumerate() {
4268            *t = match &self.prob.con_nonlinear[i] {
4269                Expr::Const(c) if *c == 0.0 => Linearity::Linear,
4270                _ => Linearity::NonLinear,
4271            };
4272        }
4273        true
4274    }
4275
4276    fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
4277        // Global linearity, per the upstream TNLP contract: a variable is
4278        // NonLinear iff it appears in the nonlinear part of the objective
4279        // or of any constraint; otherwise Linear. The parsed `.nl` splits
4280        // every row into a linear part (J/G coefficient list) and a
4281        // nonlinear expression, so the set of nonlinear variables is
4282        // exactly the structural union of `collect_vars` over
4283        // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
4284        // only by a linear part — or not referenced at all — is Linear.
4285        //
4286        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
4287        collect_vars(&self.prob.obj_nonlinear, &mut nonlinear);
4288        for row in &self.prob.con_nonlinear {
4289            collect_vars(row, &mut nonlinear);
4290        }
4291        for (i, t) in types.iter_mut().enumerate() {
4292            *t = if nonlinear.contains(&i) {
4293                Linearity::NonLinear
4294            } else {
4295                Linearity::Linear
4296            };
4297        }
4298        true
4299    }
4300
4301    fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
4302        // Objective-scoped variant of `get_variables_linearity`: only
4303        // `obj_nonlinear` contributes. This is what engages the presolve
4304        // auxiliary-elimination safeguard (pounce-presolve H11): a variable
4305        // that is nonlinear in the objective but happens to have a zero
4306        // gradient at the single probe point (e.g. `f = (x - x0)^2`
4307        // warm-started at `x0`) is kept in the objective support instead of
4308        // being mis-classified objective-free and eliminated. A variable
4309        // that is nonlinear only in *constraints* stays `Linear` here, so
4310        // the guard does not block legitimate eliminations of
4311        // objective-free equality blocks (the gas-network case).
4312        let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
4313        collect_vars(&self.prob.obj_nonlinear, &mut nonlinear);
4314        for (i, t) in types.iter_mut().enumerate() {
4315            *t = if nonlinear.contains(&i) {
4316                Linearity::NonLinear
4317            } else {
4318                Linearity::Linear
4319            };
4320        }
4321        true
4322    }
4323}
4324
4325/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
4326pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
4327    let prob = read_nl_file(path)?;
4328    Ok(Rc::new(RefCell::new(NlTnlp::new(prob))))
4329}
4330
4331#[cfg(test)]
4332mod tests {
4333    use super::*;
4334
4335    /// Compile-time guarantee for the batched-solve path (pounce#126):
4336    /// a parsed problem and the TNLP built from it must be movable to a
4337    /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
4338    /// `!Send` state) into the `Expr` DAG / tape pipeline.
4339    #[test]
4340    fn nl_problem_and_tnlp_are_send() {
4341        fn assert_send<T: Send>() {}
4342        assert_send::<NlProblem>();
4343        assert_send::<NlTnlp>();
4344        assert_send::<Expr>();
4345    }
4346
4347    /// `variant()` patches starting point / bounds on a clone and
4348    /// validates override lengths; the base instance is untouched.
4349    #[test]
4350    fn variant_overrides_bounds_and_x0() {
4351        let p = parse_nl_text(SIMPLE).expect("parse");
4352        let base = NlTnlp::new(p);
4353        let var = base
4354            .variant(&NlVariation {
4355                x0: Some(vec![3.0, 4.0]),
4356                x_l: Some(vec![-1.0, -2.0]),
4357                x_u: Some(vec![5.0, 6.0]),
4358                ..Default::default()
4359            })
4360            .expect("variant");
4361        let mut var = var;
4362        let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
4363        let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
4364        assert!(var.get_bounds_info(BoundsInfo {
4365            x_l: &mut x_l,
4366            x_u: &mut x_u,
4367            g_l: &mut g_l,
4368            g_u: &mut g_u,
4369        }));
4370        assert_eq!(x_l, [-1.0, -2.0]);
4371        assert_eq!(x_u, [5.0, 6.0]);
4372        let mut x = [0.0; 2];
4373        let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
4374        assert!(var.get_starting_point(StartingPoint {
4375            init_x: true,
4376            x: &mut x,
4377            init_z: false,
4378            z_l: &mut zl,
4379            z_u: &mut zu,
4380            init_lambda: false,
4381            lambda: &mut lam,
4382        }));
4383        assert_eq!(x, [3.0, 4.0]);
4384        // Base keeps its parsed (free) bounds.
4385        assert!(base.problem().x_l[0] < -1.0e18);
4386        // Length mismatch is an error, not a panic.
4387        assert!(
4388            base.variant(&NlVariation {
4389                x0: Some(vec![1.0]),
4390                ..Default::default()
4391            })
4392            .is_err()
4393        );
4394    }
4395
4396    /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
4397    /// Header values:
4398    ///   line 2: n=2 m=0 num_obj=1 0 0
4399    ///   line 3: 0 1   (1 nonlinear objective)
4400    ///   line 4: 0 0
4401    ///   line 5: 0 2 0 (nonlinear vars in obj=2)
4402    ///   line 6: 0 0 0 1
4403    ///   line 7: 0 0 0 0 0
4404    ///   line 8: 0 0   (no Jacobian nonzeros, no linear obj)
4405    ///   line 9: 0 0
4406    ///   line 10: 0 0 0 0 0
4407    /// Then `O0 0` followed by an expression tree:
4408    /// `(x0 - 1)^2 + (x1 - 2)^2` =
4409    ///   o0
4410    ///     o5 (o1 v0 n1) n2
4411    ///     o5 (o1 v1 n2) n2
4412    /// Then `b` segment: free for both.
4413    const SIMPLE: &str = "g3 0 1 0
44142 0 1 0 0
44150 1
44160 0
44170 2 0
44180 0 0 1
44190 0 0 0 0
44200 0
44210 0
44220 0 0 0 0
4423O0 0
4424o0
4425o5
4426o1
4427v0
4428n1
4429n2
4430o5
4431o1
4432v1
4433n2
4434n2
4435b
44363
44373
4438";
4439
4440    #[test]
4441    fn parses_simple_quadratic() {
4442        let p = parse_nl_text(SIMPLE).expect("parse");
4443        assert_eq!(p.n, 2);
4444        assert_eq!(p.m, 0);
4445        assert_eq!(p.num_obj, 1);
4446        // f(0,0) = 1 + 4 = 5
4447        let f = eval_expr(&p.obj_nonlinear, &[0.0, 0.0]);
4448        assert!((f - 5.0).abs() < 1e-12);
4449        // f(1,2) = 0
4450        let f = eval_expr(&p.obj_nonlinear, &[1.0, 2.0]);
4451        assert!(f.abs() < 1e-12);
4452    }
4453
4454    #[test]
4455    fn gradient_matches_analytic() {
4456        let p = parse_nl_text(SIMPLE).expect("parse");
4457        let x = [0.5, 1.0];
4458        let mut g = [0.0_f64; 2];
4459        grad_expr(&p.obj_nonlinear, &x, 1.0, &mut g);
4460        // d/dx0 = 2*(x0-1) = -1.0
4461        // d/dx1 = 2*(x1-2) = -2.0
4462        assert!((g[0] - (-1.0)).abs() < 1e-12);
4463        assert!((g[1] - (-2.0)).abs() < 1e-12);
4464    }
4465
4466    /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
4467    /// with global semantics so the presolve auxiliary-elimination
4468    /// safeguard actually engages. Pre-fix the default trait stub returned
4469    /// `false` and left the slice untouched, so a variable that is
4470    /// nonlinear in the objective but zero-gradient at the probe point
4471    /// could be wrongly eliminated.
4472    ///
4473    /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
4474    /// of the objective (NonLinear); x1 appears only in the linear part
4475    /// (Linear).
4476    #[test]
4477    fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
4478        // (x0 - 1)^2
4479        let obj_nl = Expr::Binary(
4480            BinOp::Pow,
4481            Box::new(Expr::Binary(
4482                BinOp::Sub,
4483                Box::new(Expr::Var(0)),
4484                Box::new(Expr::Const(1.0)),
4485            )),
4486            Box::new(Expr::Const(2.0)),
4487        );
4488        let prob = NlProblem {
4489            n: 2,
4490            m: 0,
4491            num_obj: 1,
4492            minimize: true,
4493            obj_nonlinear: obj_nl,
4494            obj_linear: vec![(1, 3.0)],
4495            obj_constant: 0.0,
4496            con_nonlinear: vec![],
4497            con_linear: vec![],
4498            x_l: vec![f64::NEG_INFINITY; 2],
4499            x_u: vec![f64::INFINITY; 2],
4500            g_l: vec![],
4501            g_u: vec![],
4502            x0: vec![0.0; 2],
4503            lambda0: vec![],
4504            suffixes: NlSuffixes::default(),
4505            imported_funcs: vec![],
4506            ampl_options: vec![],
4507            var_names: vec![],
4508            con_names: vec![],
4509        };
4510        let mut tnlp = NlTnlp::new(prob);
4511        let mut types = vec![Linearity::Linear; 2];
4512        let ok = tnlp.get_variables_linearity(&mut types);
4513        // Pre-fix: default stub returns false (slice untouched).
4514        assert!(
4515            ok,
4516            "get_variables_linearity must report it filled the slice"
4517        );
4518        assert!(
4519            matches!(types[0], Linearity::NonLinear),
4520            "x0 is nonlinear in the objective"
4521        );
4522        assert!(
4523            matches!(types[1], Linearity::Linear),
4524            "x1 appears only in the linear part"
4525        );
4526    }
4527
4528    /// Objective-scoped linearity must NOT inherit constraint
4529    /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
4530    /// (constraint tape) but linear w.r.t. the objective, so the presolve
4531    /// H11 guard must not treat it as objective-coupled — that was the CI
4532    /// regression where every gas-network variable (nonlinear in the flow
4533    /// equations, absent from the linear objective) blocked Phase-0
4534    /// elimination.
4535    #[test]
4536    fn objective_variables_linearity_ignores_constraint_nonlinearity() {
4537        // x0^2
4538        let con_nl = Expr::Binary(
4539            BinOp::Pow,
4540            Box::new(Expr::Var(0)),
4541            Box::new(Expr::Const(2.0)),
4542        );
4543        let prob = NlProblem {
4544            n: 2,
4545            m: 1,
4546            num_obj: 1,
4547            minimize: true,
4548            obj_nonlinear: Expr::Const(0.0),
4549            obj_linear: vec![(1, 3.0)],
4550            obj_constant: 0.0,
4551            con_nonlinear: vec![con_nl],
4552            con_linear: vec![vec![]],
4553            x_l: vec![f64::NEG_INFINITY; 2],
4554            x_u: vec![f64::INFINITY; 2],
4555            g_l: vec![4.0],
4556            g_u: vec![4.0],
4557            x0: vec![0.0; 2],
4558            lambda0: vec![0.0],
4559            suffixes: NlSuffixes::default(),
4560            imported_funcs: vec![],
4561            ampl_options: vec![],
4562            var_names: vec![],
4563            con_names: vec![],
4564        };
4565        let mut tnlp = NlTnlp::new(prob);
4566
4567        let mut global = vec![Linearity::Linear; 2];
4568        assert!(tnlp.get_variables_linearity(&mut global));
4569        assert!(
4570            matches!(global[0], Linearity::NonLinear),
4571            "global tags see x0's constraint nonlinearity"
4572        );
4573
4574        let mut obj = vec![Linearity::NonLinear; 2];
4575        assert!(tnlp.get_objective_variables_linearity(&mut obj));
4576        assert!(
4577            matches!(obj[0], Linearity::Linear),
4578            "x0 is linear w.r.t. the objective despite the nonlinear constraint"
4579        );
4580        assert!(
4581            matches!(obj[1], Linearity::Linear),
4582            "x1 is linear everywhere"
4583        );
4584    }
4585
4586    /// `min x0^2 + x1^2  s.t.  x0 + x1 = 1`.
4587    /// One equality constraint with a purely linear Jacobian — exercises
4588    /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
4589    /// kind 4).
4590    ///
4591    /// Header layout:
4592    ///   line 1: g3 0 1 0
4593    ///   line 2: 2 1 1 0 0   (n=2, m=1, num_obj=1)
4594    ///   line 3: 0 1         (1 nonlinear obj, 0 nonlinear cons)
4595    ///   line 4: 0 0
4596    ///   line 5: 0 2 0       (nonlinear vars in obj=2)
4597    ///   line 6: 0 0 0 1
4598    ///   line 7: 0 0 0 0 0
4599    ///   line 8: 2 0         (Jacobian nnz=2, no linear obj)
4600    ///   line 9: 0 0
4601    ///   line 10: 0 0 0 0 0
4602    /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
4603    /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
4604    const EQ_LIN: &str = "g3 0 1 0
46052 1 1 0 0
46060 1
46070 0
46080 2 0
46090 0 0 1
46100 0 0 0 0
46112 0
46120 0
46130 0 0 0 0
4614C0
4615n0
4616O0 0
4617o0
4618o5
4619v0
4620n2
4621o5
4622v1
4623n2
4624r
46254 1
4626b
46273
46283
4629k1
46302
4631J0 2
46320 1
46331 1
4634";
4635
4636    #[test]
4637    fn parses_constrained_problem() {
4638        let p = parse_nl_text(EQ_LIN).expect("parse");
4639        assert_eq!(p.n, 2);
4640        assert_eq!(p.m, 1);
4641        // r-segment kind 4 (equality with rhs=1).
4642        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
4643        assert!((p.g_u[0] - 1.0).abs() < 1e-12);
4644        // J-row 0: x0 (coef 1), x1 (coef 1).
4645        assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
4646    }
4647
4648    #[test]
4649    fn malformed_j_variable_index_is_parse_error_not_panic() {
4650        // Code review L32: a J-segment entry's variable (column) index was
4651        // pushed into con_linear unchecked, so an out-of-range index (here 5
4652        // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
4653        // constraint evaluation. It must instead surface as a clean parse
4654        // error, consistent with the existing `J<row> out of range` check.
4655        let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
4656        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
4657        let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
4658        assert!(err.contains("out of range"), "unexpected error: {err}");
4659    }
4660
4661    #[test]
4662    fn out_of_range_x_segment_index_is_parse_error() {
4663        // Same strictness for the initial-primal `x` segment: an index past
4664        // `n` used to be silently dropped; now it is a parse error, so the
4665        // four index-bearing segments (J/G/x/d) behave consistently.
4666        let bad = format!("{EQ_LIN}x1\n5 0.5\n");
4667        let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
4668        assert!(err.contains("out of range"), "unexpected error: {err}");
4669    }
4670
4671    // ---------------------------------------------------------------
4672    // gh #492 — a constant `C<i>` body folds into the row bounds.
4673    //
4674    // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
4675    // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
4676    // constant-body rows to fold.
4677    // ---------------------------------------------------------------
4678
4679    /// Linearity is a *linearity* test, not "did a `C` segment touch this
4680    /// row". A row whose body is the bare constant `3` is affine.
4681    #[test]
4682    fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
4683        // `x0 + x1 + 3 = 1`  ⇔  `x0 + x1 = -2`.
4684        let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
4685        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
4686        let p = parse_nl_text(&nl).expect("parse");
4687
4688        assert!(
4689            matches!(p.con_nonlinear[0], Expr::Const(c) if c == 0.0),
4690            "the constant body must be replaced by the identity zero, got {:?}",
4691            p.con_nonlinear[0]
4692        );
4693        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
4694        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
4695
4696        let mut lin = [Linearity::NonLinear];
4697        let mut t = NlTnlp::new(p);
4698        assert!(t.get_constraints_linearity(&mut lin));
4699        assert_eq!(lin[0], Linearity::Linear);
4700    }
4701
4702    /// The shift must be exact, not merely "linear now": the folded model
4703    /// and the hand-folded one must be the same problem, row body for row
4704    /// body and bound for bound. That is what keeps feasibility, the
4705    /// active set, and the duals unchanged.
4706    #[test]
4707    fn folding_a_row_constant_gives_the_hand_folded_problem() {
4708        // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
4709        let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
4710        let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
4711        assert_ne!(offset, EQ_LIN);
4712        assert_ne!(folded, EQ_LIN);
4713
4714        let a = parse_nl_text(&offset).expect("parse offset form");
4715        let b = parse_nl_text(&folded).expect("parse folded form");
4716        assert_eq!(a.g_l, b.g_l);
4717        assert_eq!(a.g_u, b.g_u);
4718        assert_eq!(a.con_linear, b.con_linear);
4719
4720        // And the row *values* agree pointwise, which is the property the
4721        // duals ride on: `g(x) - g_l` is the same residual either way.
4722        let mut ga = [0.0];
4723        let mut gb = [0.0];
4724        let x = [0.75, -1.25];
4725        assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
4726        assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
4727        assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
4728    }
4729
4730    /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
4731    /// constant as `n3`, and AMPL emits such trees when a expression
4732    /// collapses without being re-simplified.
4733    #[test]
4734    fn a_row_body_that_evaluates_to_a_constant_folds_too() {
4735        // `C0` = `1 + 2`.
4736        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
4737        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
4738        let p = parse_nl_text(&nl).expect("parse");
4739        assert!(matches!(p.con_nonlinear[0], Expr::Const(c) if c == 0.0));
4740        assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
4741        assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
4742    }
4743
4744    /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
4745    /// "absent", not "a very large number". Shifting one turns it into a
4746    /// real bound and invents a constraint that is not in the model.
4747    ///
4748    /// The constants here are deliberately huge. An everyday `3` is
4749    /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
4750    /// presence guard would go unnoticed at ordinary magnitudes and then
4751    /// bite on a model that scales its rows. The guard is what makes the
4752    /// sentinel untouchable at *any* magnitude.
4753    #[test]
4754    fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
4755        // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
4756        // bound. Shifting the lower sentinel would leave `-9e18`, a real
4757        // bound, so the row would gain a floor the model never stated.
4758        let nl = EQ_LIN
4759            .replace("C0\nn0\n", "C0\nn-1e18\n")
4760            .replace("r\n4 1\n", "r\n1 1\n");
4761        let p = parse_nl_text(&nl).expect("parse");
4762        assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
4763        assert!(
4764            !lower_bound_present(p.g_l[0]),
4765            "the absent-lower sentinel became a real bound: {}",
4766            p.g_l[0]
4767        );
4768
4769        // The mirror case: a positive constant on a lower-bounded row is
4770        // the one that would pull the *upper* sentinel below 1e19.
4771        let nl = EQ_LIN
4772            .replace("C0\nn0\n", "C0\nn1e18\n")
4773            .replace("r\n4 1\n", "r\n2 1\n");
4774        let p = parse_nl_text(&nl).expect("parse");
4775        assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
4776        assert!(
4777            !upper_bound_present(p.g_u[0]),
4778            "the absent-upper sentinel became a real bound: {}",
4779            p.g_u[0]
4780        );
4781    }
4782
4783    /// A row body that mentions a variable is not a constant, however
4784    /// simple it looks. Folding it would delete the term.
4785    #[test]
4786    fn a_row_body_with_a_variable_is_not_folded() {
4787        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
4788        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
4789        let p = parse_nl_text(&nl).expect("parse");
4790        assert!(
4791            !matches!(p.con_nonlinear[0], Expr::Const(_)),
4792            "a row in x0 was folded away: {:?}",
4793            p.con_nonlinear[0]
4794        );
4795        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
4796        assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
4797    }
4798
4799    /// A variable-free body whose value is not finite is left in place. It
4800    /// makes the row infeasible (or ill-posed) and that is the solver's
4801    /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
4802    /// corrupt the bound pair and take every downstream presence test with
4803    /// it.
4804    #[test]
4805    fn a_non_finite_constant_row_body_is_not_folded() {
4806        // `C0` = `log(-1)` = NaN.
4807        let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
4808        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
4809        let p = parse_nl_text(&nl).expect("parse");
4810        assert!(
4811            !matches!(p.con_nonlinear[0], Expr::Const(c) if c == 0.0),
4812            "a NaN body was folded into the bounds"
4813        );
4814        assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
4815        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
4816    }
4817
4818    /// An imported-function call is not a parse-time constant even with
4819    /// constant arguments — it is resolved to a shared library much later
4820    /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
4821    /// rather than guess. The fold must decline before it evaluates.
4822    #[test]
4823    fn a_constant_argument_funcall_row_body_is_not_folded() {
4824        // Declare one imported function and call it with a literal.
4825        let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
4826        assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
4827        let p = parse_nl_text(&nl).expect("parse");
4828        assert!(
4829            matches!(p.con_nonlinear[0], Expr::Funcall { .. }),
4830            "expected the funcall to survive the fold, got {:?}",
4831            p.con_nonlinear[0]
4832        );
4833        assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
4834    }
4835
4836    #[test]
4837    fn k_segment_nonstandard_count_is_parse_error_at_source() {
4838        // Code review L35: the `k` (Jacobian column-count) segment header
4839        // declares how many count lines follow — `k<count>` — and the
4840        // standard value is n-1. The parser used to *assume* n-1 and ignore
4841        // the header, so a file declaring a different count read the wrong
4842        // number of data lines, desynced the segment stream, and failed far
4843        // downstream with a confusing error (or silently mis-parsed). With
4844        // the declared count now read and validated, a nonstandard count is
4845        // a clear parse error at its source. Here EQ_LIN has n=2 (expected
4846        // count 1); rewrite its `k1` + one count line to `k0`.
4847        let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
4848        assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
4849        let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
4850        assert!(
4851            err.contains("k-segment declares"),
4852            "expected a clear k-segment count error, got: {err}"
4853        );
4854    }
4855
4856    #[test]
4857    fn get_starting_point_returns_nl_initial_duals() {
4858        // Code review 2026-06 item M19: the `.nl` `d` segment supplies
4859        // initial constraint multipliers. They are parsed into `lambda0`,
4860        // but `get_starting_point` previously ignored them — so a
4861        // `warm_start_init_point yes` solve silently began from zero duals.
4862        // `get_starting_point` must hand the parsed duals back when the
4863        // engine requests them (`init_lambda`), and leave the buffer
4864        // untouched when it does not.
4865        let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
4866        let p = parse_nl_text(&nl).expect("parse");
4867        assert_eq!(p.lambda0, vec![2.5], "the `d` segment fills lambda0");
4868
4869        let mut t = NlTnlp::new(p);
4870        let info = t.get_nlp_info().unwrap();
4871        let (n, m) = (info.n as usize, info.m as usize);
4872
4873        // Warm-start request: init_lambda = true → the parsed `.nl` duals
4874        // must be returned (pre-fix this stayed zero).
4875        let mut x = vec![0.0; n];
4876        let mut z_l = vec![0.0; n];
4877        let mut z_u = vec![0.0; n];
4878        let mut lambda = vec![0.0; m];
4879        assert!(t.get_starting_point(StartingPoint {
4880            init_x: true,
4881            x: &mut x,
4882            init_z: false,
4883            z_l: &mut z_l,
4884            z_u: &mut z_u,
4885            init_lambda: true,
4886            lambda: &mut lambda,
4887        }));
4888        assert_eq!(
4889            lambda,
4890            vec![2.5],
4891            "a warm start must use the `.nl` initial duals, not zero"
4892        );
4893
4894        // No warm-start request: the multiplier buffer is left alone (the
4895        // engine owns its default), so honoring the flag does not clobber it.
4896        let mut lambda_untouched = vec![7.0; m];
4897        assert!(t.get_starting_point(StartingPoint {
4898            init_x: true,
4899            x: &mut x,
4900            init_z: false,
4901            z_l: &mut z_l,
4902            z_u: &mut z_u,
4903            init_lambda: false,
4904            lambda: &mut lambda_untouched,
4905        }));
4906        assert_eq!(
4907            lambda_untouched,
4908            vec![7.0],
4909            "without init_lambda the multiplier buffer must be untouched"
4910        );
4911    }
4912
4913    /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
4914    /// unconstrained, `n` variables. The trailing product puts a nonzero
4915    /// in row 0 of *every* Hessian column, which is the shape that used
4916    /// to force the greedy coloring to hand out one color per variable.
4917    fn dense_row_objective_nl(n: usize) -> String {
4918        let mut s = String::new();
4919        s.push_str("g3 1 1 0\n");
4920        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
4921        s.push_str(" 0 1\n 0 0\n");
4922        s.push_str(&format!(" {n} {n} {n}\n"));
4923        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
4924        s.push_str(&format!(" 0 {n}\n"));
4925        s.push_str(" 0 0\n 0 0 0 0 0\n");
4926        // objective
4927        s.push_str("O0 0\n");
4928        s.push_str(&format!("o54\n{}\n", n + 1));
4929        for j in 0..n {
4930            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
4931        }
4932        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
4933        for j in 0..n {
4934            s.push_str(&format!("v{j}\n"));
4935        }
4936        // start, bounds, gradient
4937        s.push_str(&format!("x{n}\n"));
4938        for j in 0..n {
4939            s.push_str(&format!("{j} 0.5\n"));
4940        }
4941        s.push_str("b\n");
4942        for _ in 0..n {
4943            s.push_str("3\n");
4944        }
4945        s.push_str(&format!("G0 {n}\n"));
4946        for j in 0..n {
4947            s.push_str(&format!("{j} 0.0\n"));
4948        }
4949        s
4950    }
4951
4952    /// Same shape as [`dense_row_objective_nl`], but the coupling term
4953    /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
4954    /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
4955    /// magnitude makes the dense column ill-scaled — the case where
4956    /// reading its entries out of its own pass costs real digits.
4957    fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
4958        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
4959        let mut s = String::new();
4960        s.push_str("g3 1 1 0\n");
4961        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
4962        s.push_str(" 0 1\n 0 0\n");
4963        s.push_str(&format!(" {n} {n} {n}\n"));
4964        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
4965        s.push_str(&format!(" 0 {n}\n"));
4966        s.push_str(" 0 0\n 0 0 0 0 0\n");
4967        s.push_str("O0 0\n");
4968        s.push_str(&format!("o54\n{}\n", n + 1));
4969        for j in 0..n {
4970            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
4971        }
4972        s.push_str(&format!("o2\nv0\no54\n{n}\n"));
4973        for j in 0..n {
4974            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
4975        }
4976        s.push_str(&format!("x{n}\n"));
4977        for j in 0..n {
4978            s.push_str(&format!("{j} 0.5\n"));
4979        }
4980        s.push_str("b\n");
4981        for _ in 0..n {
4982            s.push_str("3\n");
4983        }
4984        s.push_str(&format!("G0 {n}\n"));
4985        for j in 0..n {
4986            s.push_str(&format!("{j} 0.0\n"));
4987        }
4988        s
4989    }
4990
4991    /// Locate a model in the benchmark corpus, or `None` if the corpus is
4992    /// not on this machine.
4993    ///
4994    /// The corpus is ~2 GB and deliberately outside the checkout (see
4995    /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
4996    /// no-op rather than fail. That is a real limitation — a check that
4997    /// silently does nothing is how the gap below went unnoticed in the
4998    /// first place — so anything using this must also be covered by a
4999    /// synthetic case that always runs.
5000    fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
5001        let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
5002        let p = std::path::PathBuf::from(root).join(rel);
5003        p.is_file().then_some(p)
5004    }
5005
5006    /// The corpus check the dense-column optimization never got.
5007    ///
5008    /// `cho_parmest` is the model whose certificate the optimization cost,
5009    /// and it could not have caught it: the model is 4.3 MB and lives in
5010    /// the benchmark data set, not in the repository, so validating
5011    /// against the in-repo `.nl` fixtures said nothing about it. This test
5012    /// closes that by checking the corpus directly wherever the corpus
5013    /// exists, which is every machine and CI job that runs the benchmarks.
5014    ///
5015    /// The assertion is the one that matters and the one that was never
5016    /// made: whatever the guard leaves peeled must decode to the same
5017    /// Hessian as peeling nothing. Against the pre-fix code this fails —
5018    /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
5019    #[test]
5020    fn cho_parmest_decodes_to_its_unpeeled_reference() {
5021        let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
5022            eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
5023            return;
5024        };
5025        let p = read_nl_file(&path).expect("read cho_parmest");
5026        let n = p.n;
5027        let mut t = NlTnlp::new(p);
5028        // The guard vetoes 7 of cho's 12 peeled columns, and putting those
5029        // seven dense rows back into the conflict graph costs the coloring
5030        // outright: it goes from 17 colors to 9010, and no column clears the
5031        // density threshold afterwards, so the model ends up fully unpeeled.
5032        // That is the price of the certificate on this model, and it is worth
5033        // knowing rather than assuming the other five survive.
5034        assert!(t.peeled_cols.is_empty());
5035
5036        let info = t.get_nlp_info().unwrap();
5037        let nnz = info.nnz_h_lag as usize;
5038        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
5039        assert!(t.eval_h(
5040            None,
5041            true,
5042            1.0,
5043            None,
5044            true,
5045            SparsityRequest::Structure {
5046                irow: &mut irow,
5047                jcol: &mut jcol
5048            }
5049        ));
5050        // Evaluate away from `x0` with non-uniform multipliers. The damage
5051        // is point-dependent, and `x0` is close to where it is least
5052        // visible: only 5 entries move there, against 48,931 here. Note the
5053        // guard's own probe runs at `x0` — it fires anyway because it tests
5054        // a bound on what a pass *could* lose, not the damage it happens to
5055        // commit at one point. That is the property that makes it robust,
5056        // and this asymmetry is worth keeping in front of anyone who
5057        // retunes it.
5058        let x: Vec<f64> = t
5059            .prob
5060            .x0
5061            .iter()
5062            .enumerate()
5063            .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
5064            .collect();
5065        let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
5066
5067        let mut got = vec![0.0_f64; nnz];
5068        assert!(t.eval_h(
5069            Some(&x),
5070            true,
5071            1.0,
5072            Some(&lambda),
5073            true,
5074            SparsityRequest::Values { values: &mut got }
5075        ));
5076
5077        t.recolor(&vec![true; n]);
5078        assert!(t.peeled_cols.is_empty());
5079        let mut want = vec![0.0_f64; nnz];
5080        assert!(t.eval_h(
5081            Some(&x),
5082            true,
5083            1.0,
5084            Some(&lambda),
5085            true,
5086            SparsityRequest::Values { values: &mut want }
5087        ));
5088
5089        let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
5090        let mut worst = 0.0_f64;
5091        let mut at = 0usize;
5092        for k in 0..nnz {
5093            let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
5094            if rel > worst {
5095                worst = rel;
5096                at = k;
5097            }
5098        }
5099        assert!(
5100            worst <= 1e-13,
5101            "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
5102             {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
5103             out of a pass it cannot be read out of.",
5104            irow[at],
5105            jcol[at],
5106            got[at],
5107            want[at]
5108        );
5109
5110        // The check above passes trivially on correct code, because the
5111        // guard leaves cho fully unpeeled and so compares a configuration
5112        // against itself. It only has teeth against a regression. So prove
5113        // the guard is doing necessary work rather than assuming it: put
5114        // the peels back the way the pre-fix reader had them and confirm
5115        // the Hessian really does come apart.
5116        t.recolor(&vec![false; n]);
5117        assert!(
5118            !t.peeled_cols.is_empty(),
5119            "restoring the unvetoed coloring must peel again"
5120        );
5121        let mut unguarded = vec![0.0_f64; nnz];
5122        assert!(t.eval_h(
5123            Some(&x),
5124            true,
5125            1.0,
5126            Some(&lambda),
5127            true,
5128            SparsityRequest::Values {
5129                values: &mut unguarded
5130            }
5131        ));
5132        let mut bad = 0usize;
5133        let mut worst_unguarded = 0.0_f64;
5134        for k in 0..nnz {
5135            let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
5136            if rel > 1e-13 {
5137                bad += 1;
5138            }
5139            worst_unguarded = worst_unguarded.max(rel);
5140        }
5141        // Two different counts get quoted about this model and they measure
5142        // different things: 48,931 of the 96,000 entries differ from the
5143        // uncompressed reference *at all*, down to the last bit, while 88
5144        // exceed 1e-13 relative. The second is the one worth asserting on.
5145        assert!(
5146            bad >= 50 && worst_unguarded > 1e-11,
5147            "peeling cho_parmest unguarded should damage the entries the guard \
5148             exists to protect (measured: 88 entries past 1e-13 relative, \
5149             worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
5150             this fires, the model or the corpus changed and the guard's \
5151             calibration should be re-derived rather than the bound relaxed."
5152        );
5153        eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
5154    }
5155
5156    /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
5157    /// the dense variable is `x_{n-1}` instead of `x_0`:
5158    /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
5159    ///
5160    /// The index matters, and it is the whole reason this helper exists.
5161    /// With the dense variable at 0 every coupling entry is stored as
5162    /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
5163    /// out of column 0's own pass whether or not peeling is on, so the two
5164    /// paths are bit-identical and no test built on that shape can tell
5165    /// them apart. Putting the dense variable last stores them as
5166    /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
5167    /// back from column `n-1`'s pass by symmetry, carrying that pass's
5168    /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
5169    /// That is the category every one of `cho_parmest`'s 48,931 damaged
5170    /// entries fell into.
5171    fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
5172        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
5173        let d = n - 1;
5174        let mut s = String::new();
5175        s.push_str("g3 1 1 0\n");
5176        s.push_str(&format!(" {n} 0 1 0 0 0\n"));
5177        s.push_str(" 0 1\n 0 0\n");
5178        s.push_str(&format!(" {n} {n} {n}\n"));
5179        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
5180        s.push_str(&format!(" 0 {n}\n"));
5181        s.push_str(" 0 0\n 0 0 0 0 0\n");
5182        s.push_str("O0 0\n");
5183        s.push_str(&format!("o54\n{}\n", n + 1));
5184        for j in 0..n {
5185            s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
5186        }
5187        s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
5188        for j in 0..d {
5189            s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
5190        }
5191        s.push_str(&format!("x{n}\n"));
5192        for j in 0..n {
5193            s.push_str(&format!("{j} 0.5\n"));
5194        }
5195        s.push_str("b\n");
5196        for _ in 0..n {
5197            s.push_str("3\n");
5198        }
5199        s.push_str(&format!("G0 {n}\n"));
5200        for j in 0..n {
5201            s.push_str(&format!("{j} 0.0\n"));
5202        }
5203        s
5204    }
5205
5206    /// A well-scaled dense column is still peeled, and the entries read
5207    /// out of its pass are exact — the property the peel guard must not
5208    /// cost us.
5209    #[test]
5210    fn a_well_scaled_dense_column_is_still_peeled() {
5211        let n = 200;
5212        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
5213        let t = NlTnlp::new(p);
5214        assert_eq!(
5215            t.peeled_cols,
5216            vec![0],
5217            "a dense column that costs no accuracy must stay peeled"
5218        );
5219        assert!(
5220            t.seeds.len() <= 4,
5221            "peeling should keep the color count at O(1), got {}",
5222            t.seeds.len()
5223        );
5224    }
5225
5226    /// An ill-scaled dense column must be un-peeled and colored the
5227    /// ordinary way, so its small entries come back to full precision.
5228    ///
5229    /// This test covers the *guard*, not the damage. It asserts that a
5230    /// column spanning 12 orders is un-peeled and that its entries are then
5231    /// exact — and it is worth being precise that it does not, and cannot,
5232    /// show what peeling would have cost, because on this model peeling
5233    /// costs nothing: force the peel through and every entry still comes
5234    /// back bit-identical to its analytic weight. The Hessian here is
5235    /// constant and each entry is a single product, so there is no
5236    /// accumulation for a large-magnitude pass to pollute.
5237    ///
5238    /// Reproducing the actual digit loss takes a model whose entries are
5239    /// summed through shared intermediates, which is why the demonstration
5240    /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
5241    /// real model rather than a synthetic one.
5242    #[test]
5243    fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
5244        let n = 200;
5245        let span = 12.0;
5246        let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
5247        let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
5248        let mut t = NlTnlp::new(p);
5249
5250        assert!(
5251            t.peeled_cols.is_empty(),
5252            "a dense column spanning {span} orders must not be peeled; got {:?}",
5253            t.peeled_cols
5254        );
5255
5256        let info = t.get_nlp_info().unwrap();
5257        let nnz = info.nnz_h_lag as usize;
5258        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
5259        assert!(t.eval_h(
5260            None,
5261            true,
5262            1.0,
5263            None,
5264            true,
5265            SparsityRequest::Structure {
5266                irow: &mut irow,
5267                jcol: &mut jcol
5268            }
5269        ));
5270        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
5271        let mut vals = vec![0.0_f64; nnz];
5272        assert!(t.eval_h(
5273            Some(&x),
5274            true,
5275            1.0,
5276            None,
5277            true,
5278            SparsityRequest::Values { values: &mut vals }
5279        ));
5280
5281        // Every coupling entry must be its weight to full relative
5282        // precision. Peeled, the smallest of them came back with a
5283        // relative error near 1e-4.
5284        let mut checked = 0;
5285        for k in 0..nnz {
5286            let (i, j) = (irow[k] as usize, jcol[k] as usize);
5287            if i == j {
5288                continue;
5289            }
5290            assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
5291            checked += 1;
5292            let want = w(i);
5293            assert!(
5294                (vals[k] - want).abs() <= 1e-13 * want,
5295                "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
5296                vals[k],
5297                (vals[k] - want).abs() / want
5298            );
5299        }
5300        assert_eq!(checked, n - 1);
5301    }
5302
5303    /// Whatever survives the peel guard must decode to the same Hessian
5304    /// that not peeling at all produces — every entry, not just the
5305    /// objective.
5306    ///
5307    /// This exists because the original dense-column optimization was
5308    /// validated by running the repository's `.nl` fixtures and comparing
5309    /// objective value and exit status, which was doubly blind: not one of
5310    /// the 60 fixtures has a Hessian row dense enough to peel anything, so
5311    /// the decode path under test never executed, and even had it executed,
5312    /// the defect cost digits in the multipliers while leaving the
5313    /// objective intact. So the instrument has to be the assembled Hessian
5314    /// and the input has to actually peel — hence `peeled_any` below, which
5315    /// fails if the sweep ever goes vacuous the way the fixture suite
5316    /// silently did.
5317    ///
5318    /// What this catches is a *decode* fault: an entry recovered from the
5319    /// wrong pass, which is wrong by O(1). It would not have caught the
5320    /// `cho_parmest` stall, because these synthetic models lose no
5321    /// precision under peeling at all (see
5322    /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
5323    /// precision half is covered against the real model in
5324    /// `cho_parmest_decodes_to_its_unpeeled_reference`.
5325    #[test]
5326    fn a_peeled_decode_matches_an_unpeeled_reference() {
5327        // `last = true` puts the dense variable at `n-1`, so the coupling
5328        // entries are stored with the peeled column as their *row* — the
5329        // only shape in which peeling and not peeling read an entry out of
5330        // different passes, and so the only shape that can detect a
5331        // difference at all. See `weighted_dense_last_col_nl`.
5332        let cases = [
5333            (200, 0.0, false),
5334            (200, 2.0, false),
5335            (200, 0.0, true),
5336            (200, 1.0, true),
5337            (200, 2.0, true),
5338            (400, 3.0, true),
5339            (600, 0.0, true),
5340        ];
5341        let mut peeled_any = false;
5342        let mut worst = 0.0_f64;
5343
5344        for &(n, span, last) in &cases {
5345            let text = if last {
5346                weighted_dense_last_col_nl(n, span)
5347            } else {
5348                weighted_dense_row_objective_nl(n, span)
5349            };
5350            let p = parse_nl_text(&text).expect("parse");
5351            let mut t = NlTnlp::new(p);
5352            let peeled = t.peeled_cols.clone();
5353            peeled_any |= !peeled.is_empty();
5354
5355            let info = t.get_nlp_info().unwrap();
5356            let nnz = info.nnz_h_lag as usize;
5357            let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
5358            assert!(t.eval_h(
5359                None,
5360                true,
5361                1.0,
5362                None,
5363                true,
5364                SparsityRequest::Structure {
5365                    irow: &mut irow,
5366                    jcol: &mut jcol
5367                }
5368            ));
5369            let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
5370
5371            let mut got = vec![0.0_f64; nnz];
5372            assert!(t.eval_h(
5373                Some(&x),
5374                true,
5375                1.0,
5376                None,
5377                true,
5378                SparsityRequest::Values { values: &mut got }
5379            ));
5380
5381            // Same object, same tapes, same point — only the coloring
5382            // differs, so any disagreement is the decode path.
5383            t.recolor(&vec![true; n]);
5384            assert!(
5385                t.peeled_cols.is_empty(),
5386                "a fully vetoed model must peel nothing"
5387            );
5388            let mut want = vec![0.0_f64; nnz];
5389            assert!(t.eval_h(
5390                Some(&x),
5391                true,
5392                1.0,
5393                None,
5394                true,
5395                SparsityRequest::Values { values: &mut want }
5396            ));
5397
5398            for k in 0..nnz {
5399                let scale = want[k].abs().max(f64::MIN_POSITIVE);
5400                let rel = (got[k] - want[k]).abs() / scale;
5401                worst = worst.max(rel);
5402                assert!(
5403                    rel <= 1e-13,
5404                    "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
5405                     unpeeled reference {:e} (relative error {rel:e})",
5406                    irow[k],
5407                    jcol[k],
5408                    got[k],
5409                    want[k]
5410                );
5411            }
5412        }
5413
5414        assert!(
5415            peeled_any,
5416            "no case peeled anything, so this test proved nothing about the \
5417             decode path — the exact way the fixture suite missed the bug"
5418        );
5419        assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
5420    }
5421
5422    /// `peel_veto` bars a column from the peel set, and the row it puts
5423    /// back into the conflict structure costs the colors it used to save.
5424    #[test]
5425    fn a_vetoed_column_is_colored_the_ordinary_way() {
5426        let n = 300;
5427        // One dense row plus a diagonal: column 0 touches every row.
5428        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
5429        pairs.extend((1..n).map(|i| (i, 0)));
5430        pairs.sort_unstable();
5431
5432        let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
5433        assert!(peeled[0], "the dense column should peel by default");
5434        assert!(
5435            colors_peeled <= 4,
5436            "peeling should collapse the count, got {colors_peeled}"
5437        );
5438
5439        let mut veto = vec![false; n];
5440        veto[0] = true;
5441        let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
5442        assert!(!peeled[0], "a vetoed column must not be peeled");
5443        assert!(
5444            colors_vetoed > colors_peeled,
5445            "un-peeling restores row 0's conflicts, so colors must rise: \
5446             {colors_vetoed} vs {colors_peeled}"
5447        );
5448    }
5449
5450    /// A single dense Hessian row must not blow the coloring up to one
5451    /// color per variable. It used to: every column shares row 0, so no
5452    /// two columns could be colored alike, and `seeds` / `compressed`
5453    /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
5454    /// per `eval_h` on a Hessian holding only ~2n nonzeros.
5455    #[test]
5456    fn a_dense_hessian_row_does_not_explode_the_coloring() {
5457        let n = 200;
5458        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
5459        let t = NlTnlp::new(p);
5460        // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
5461        // for j >= 1 from the coupling term.
5462        assert_eq!(t.h_irow.len(), 2 * n - 1);
5463        assert!(
5464            t.seeds.len() <= 4,
5465            "one dense row should cost one extra color, not n; got {} colors for n={n}",
5466            t.seeds.len()
5467        );
5468        assert_eq!(t.seeds.len(), t.compressed.len());
5469    }
5470
5471    /// Peeling changes *which* directional product recovers an entry, so
5472    /// the recovered Hessian must still be exactly right — including the
5473    /// entries read out of a peeled column's pass by symmetry.
5474    #[test]
5475    fn peeled_dense_column_still_recovers_the_exact_hessian() {
5476        let n = 200;
5477        let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
5478        let mut t = NlTnlp::new(p);
5479        let info = t.get_nlp_info().unwrap();
5480        let nnz = info.nnz_h_lag as usize;
5481
5482        let mut irow = vec![0_i32; nnz];
5483        let mut jcol = vec![0_i32; nnz];
5484        assert!(t.eval_h(
5485            None,
5486            true,
5487            1.0,
5488            None,
5489            true,
5490            SparsityRequest::Structure {
5491                irow: &mut irow,
5492                jcol: &mut jcol
5493            }
5494        ));
5495
5496        // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
5497        //   H[0,0] = 2 + 2 = 4;  H[j,j] = 2 (j >= 1);  H[j,0] = 1 (j >= 1).
5498        let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
5499        let obj_factor = 2.5;
5500        let mut vals = vec![0.0_f64; nnz];
5501        assert!(t.eval_h(
5502            Some(&x),
5503            true,
5504            obj_factor,
5505            None,
5506            true,
5507            SparsityRequest::Values { values: &mut vals }
5508        ));
5509
5510        let mut seen_diag = 0;
5511        let mut seen_coupling = 0;
5512        for k in 0..nnz {
5513            let (i, j) = (irow[k] as usize, jcol[k] as usize);
5514            let want = if i == 0 && j == 0 {
5515                4.0
5516            } else if i == j {
5517                seen_diag += 1;
5518                2.0
5519            } else {
5520                assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
5521                seen_coupling += 1;
5522                1.0
5523            } * obj_factor;
5524            assert!(
5525                (vals[k] - want).abs() < 1e-12,
5526                "H[{i},{j}] = {}, want {want}",
5527                vals[k]
5528            );
5529        }
5530        assert_eq!(seen_diag, n - 1);
5531        assert_eq!(seen_coupling, n - 1);
5532    }
5533
5534    /// The peeling threshold must leave ordinary sparse models on
5535    /// exactly the coloring they had before: a banded Hessian colors by
5536    /// its bandwidth, with nothing peeled.
5537    #[test]
5538    fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
5539        let n = 400;
5540        let pairs: Vec<(usize, usize)> = (0..n)
5541            .flat_map(|j| {
5542                let mut v = vec![(j, j)];
5543                if j + 1 < n {
5544                    v.push((j + 1, j));
5545                }
5546                v
5547            })
5548            .collect();
5549        let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
5550        assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
5551        assert!(
5552            n_colors <= 3,
5553            "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
5554        );
5555        assert!(var_color.iter().all(|&c| c != u32::MAX));
5556    }
5557
5558    /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
5559    /// scattered through an otherwise diagonal `n`-variable problem.
5560    /// A plain coloring needs exactly `size` colors no matter how many
5561    /// blocks there are, because the blocks share no rows.
5562    fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
5563        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
5564        let stride = n / blocks;
5565        for b in 0..blocks {
5566            let base = b * stride;
5567            for i in 0..size {
5568                for j in 0..=i {
5569                    if i != j {
5570                        pairs.push((base + i, base + j));
5571                    }
5572                }
5573            }
5574        }
5575        pairs
5576    }
5577
5578    /// Many medium-degree columns must not be peeled.
5579    ///
5580    /// Regression: selecting candidates by "degree > 16x average" and then
5581    /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
5582    /// The columns that miss the cut stay in the conflict structure, so the
5583    /// base color count is untouched and the singleton colors are pure
5584    /// addition — these two patterns colored to 306 and 290 against a plain
5585    /// walk's 50 and 34.
5586    #[test]
5587    fn thousands_of_medium_degree_cols_are_not_peeled() {
5588        for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
5589            let pairs = disjoint_blocks(n, blocks, size);
5590            let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
5591            let n_peeled = peeled.iter().filter(|&&p| p).count();
5592            assert_eq!(
5593                n_peeled, 0,
5594                "degree-{size} columns do not pay for a singleton color \
5595                 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
5596            );
5597            assert!(
5598                n_colors <= size + 1,
5599                "disjoint {size}x{size} blocks color by block size regardless \
5600                 of block count; got {n_colors} for n={n}, blocks={blocks}"
5601            );
5602        }
5603    }
5604
5605    /// The pay-for-itself rule must not throw away the case peeling exists
5606    /// for: a handful of genuinely dense rows still get peeled, and still
5607    /// collapse the coloring.
5608    #[test]
5609    fn a_few_truly_dense_rows_are_still_peeled() {
5610        let n = 5_000;
5611        let dense_rows = 4;
5612        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
5613        for d in 0..dense_rows {
5614            for j in 0..n {
5615                if j != d {
5616                    pairs.push((j.max(d), j.min(d)));
5617                }
5618            }
5619        }
5620        let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
5621        let n_peeled = peeled.iter().filter(|&&p| p).count();
5622        assert_eq!(n_peeled, dense_rows, "every full row should peel");
5623        assert!(
5624            n_colors <= dense_rows + 2,
5625            "peeling {dense_rows} full rows should leave a diagonal remainder, \
5626             got {n_colors} colors"
5627        );
5628    }
5629
5630    /// The cap still binds, and when it does the kept columns are the
5631    /// highest-degree ones.
5632    #[test]
5633    fn peeling_is_capped_and_keeps_the_worst_offenders() {
5634        let n = 20_000;
5635        let dense_rows = 400;
5636        let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
5637        for d in 0..dense_rows {
5638            // Row d touches the first (n - d) columns, so degree strictly
5639            // decreases with d and the ordering is unambiguous.
5640            for j in 0..(n - d) {
5641                if j != d {
5642                    pairs.push((j.max(d), j.min(d)));
5643                }
5644            }
5645        }
5646        let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
5647        let n_peeled = peeled.iter().filter(|&&p| p).count();
5648        assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
5649        assert!(
5650            (0..MAX_PEELED_COLS).all(|d| peeled[d]),
5651            "the {MAX_PEELED_COLS} densest rows are the ones kept"
5652        );
5653    }
5654
5655    /// Header line 0 is `g<count> <opt0> ...`; the option words are the
5656    /// model's own and a solver echoes them into the `.sol` `Options`
5657    /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
5658    #[test]
5659    fn header_option_words_are_kept_verbatim() {
5660        let p = parse_nl_text(EQ_LIN).expect("parse");
5661        assert_eq!(p.ampl_options, vec![0, 1, 0]);
5662    }
5663
5664    /// A count that does not match the words present must not be
5665    /// guessed at — the writer falls back rather than emit a wrong block.
5666    #[test]
5667    fn a_truncated_option_list_is_dropped_not_padded() {
5668        let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
5669        let p = parse_nl_text(&text).expect("parse");
5670        assert!(
5671            p.ampl_options.is_empty(),
5672            "9 declared but 3 present: {:?}",
5673            p.ampl_options
5674        );
5675    }
5676
5677    #[test]
5678    fn constrained_tnlp_eval_g_jac_h() {
5679        let p = parse_nl_text(EQ_LIN).expect("parse");
5680        let mut t = NlTnlp::new(p);
5681        let info = t.get_nlp_info().unwrap();
5682        assert_eq!(info.m, 1);
5683        assert_eq!(info.nnz_jac_g, 2);
5684
5685        // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
5686        let mut g = [0.0_f64; 1];
5687        assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
5688        assert!((g[0] - 0.7).abs() < 1e-12);
5689
5690        // Jacobian structure: row 0, cols [0, 1].
5691        let mut irow = [0_i32; 2];
5692        let mut jcol = [0_i32; 2];
5693        assert!(t.eval_jac_g(
5694            None,
5695            true,
5696            SparsityRequest::Structure {
5697                irow: &mut irow,
5698                jcol: &mut jcol
5699            }
5700        ));
5701        assert_eq!(irow, [0, 0]);
5702        assert_eq!(jcol, [0, 1]);
5703
5704        // Jacobian values: both 1.0.
5705        let mut vals = [0.0_f64; 2];
5706        assert!(t.eval_jac_g(
5707            Some(&[0.3, 0.4]),
5708            true,
5709            SparsityRequest::Values { values: &mut vals }
5710        ));
5711        assert!((vals[0] - 1.0).abs() < 1e-12);
5712        assert!((vals[1] - 1.0).abs() < 1e-12);
5713
5714        // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
5715        // λ contributes nothing because the constraint is linear, and
5716        // x0^2 + x1^2 is separable so there's no (1,0) entry in the
5717        // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
5718        assert_eq!(info.nnz_h_lag, 2);
5719        let mut hirow = [0_i32; 2];
5720        let mut hjcol = [0_i32; 2];
5721        assert!(t.eval_h(
5722            None,
5723            true,
5724            1.0,
5725            None,
5726            true,
5727            SparsityRequest::Structure {
5728                irow: &mut hirow,
5729                jcol: &mut hjcol
5730            }
5731        ));
5732        assert_eq!(hirow, [0, 1]);
5733        assert_eq!(hjcol, [0, 1]);
5734        let mut hvals = [0.0_f64; 2];
5735        assert!(t.eval_h(
5736            Some(&[0.3, 0.4]),
5737            true,
5738            1.0,
5739            Some(&[0.5]),
5740            true,
5741            SparsityRequest::Values { values: &mut hvals }
5742        ));
5743        assert!((hvals[0] - 2.0).abs() < 1e-12);
5744        assert!((hvals[1] - 2.0).abs() < 1e-12);
5745    }
5746
5747    /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
5748    /// encoded as common-subexpression `V2`. Header line 10 declares
5749    /// one obj-only CSE; expression tree references `v2` twice.
5750    const CSE_OBJ: &str = "g3 0 1 0
57512 0 1 0 0
57520 1
57530 0
57540 2 0
57550 0 0 1
57560 0 0 0 0
57570 0
57580 0
57590 1 0 0 0
5760V2 0 0
5761o0
5762v0
5763v1
5764O0 0
5765o0
5766o5
5767v2
5768n2
5769v2
5770b
57713
57723
5773";
5774
5775    #[test]
5776    fn parses_v_segment_cse() {
5777        let p = parse_nl_text(CSE_OBJ).expect("parse");
5778        assert_eq!(p.n, 2);
5779        // f(1,2) = 9 + 3 = 12
5780        let f = eval_expr(&p.obj_nonlinear, &[1.0, 2.0]);
5781        assert!((f - 12.0).abs() < 1e-12, "got {f}");
5782        // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
5783        let mut g = [0.0_f64; 2];
5784        grad_expr(&p.obj_nonlinear, &[1.0, 2.0], 1.0, &mut g);
5785        assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
5786        assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
5787        // collect_vars reaches into the CSE body and finds {0, 1}.
5788        let mut vs = BTreeSet::new();
5789        collect_vars(&p.obj_nonlinear, &mut vs);
5790        assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
5791    }
5792
5793    /// `min (x0 - 1)^2` with three suffix segments attached: an
5794    /// integer constraint-suffix (target=1, kind=1), an integer var-
5795    /// suffix (target=0, kind=0), and a real var-suffix (target=0,
5796    /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
5797    /// `<idx> <value>` lines.
5798    const WITH_SUFFIXES: &str = "g3 0 1 0
57991 0 1 0 0
58000 1
58010 0
58020 1 0
58030 0 0 1
58040 0 0 0 0
58050 0
58060 0
58070 0 0 0 0
5808O0 0
5809o5
5810o1
5811v0
5812n1
5813n2
5814b
58153
5816S0 1 sens_state_1
58170 7
5818S4 1 sens_state_value_1
58190 4.5
5820";
5821
5822    #[test]
5823    fn parses_var_int_and_var_real_suffixes() {
5824        let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
5825        // Integer var-suffix: dense length 1, slot 0 = 7.
5826        let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
5827        assert_eq!(v.as_slice(), &[7]);
5828        // Real var-suffix: dense length 1, slot 0 = 4.5.
5829        let r = p
5830            .suffixes
5831            .var_real
5832            .get("sens_state_value_1")
5833            .expect("var_real");
5834        assert_eq!(r.len(), 1);
5835        assert!((r[0] - 4.5).abs() < 1e-12);
5836        // Other suffix slots stay empty.
5837        assert!(p.suffixes.con_int.is_empty());
5838        assert!(p.suffixes.con_real.is_empty());
5839    }
5840
5841    /// Two-variable + two-constraint problem with a constraint-level
5842    /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
5843    const WITH_CON_SUFFIX: &str = "g3 0 1 0
58442 2 1 0 0
58450 0
58460 0
58470 2 0
58480 0 0 1
58490 0 0 0 0
58502 0
58510 0
58520 0 0 0 0 0
5853C0
5854n0
5855C1
5856n0
5857O0 0
5858n0
5859r
58604 0.0
58614 0.0
5862b
58633
58643
5865k1
58660
5867J0 2
58680 1
58691 1
5870J1 2
58710 1
58721 -1
5873S1 2 sens_init_constr
58740 1
58751 2
5876";
5877
5878    #[test]
5879    fn parses_con_int_suffix() {
5880        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
5881        let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
5882        // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
5883        assert_eq!(s.as_slice(), &[1, 2]);
5884    }
5885
5886    /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
5887    /// (n = m = 2) and hand back everything the engine would see.
5888    fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
5889        let mut obj = 1.0;
5890        let mut use_x = false;
5891        let mut x = vec![0.0; 2];
5892        let mut use_g = false;
5893        let mut g = vec![0.0; 2];
5894        let ok = tnlp.get_scaling_parameters(ScalingRequest {
5895            obj_scaling: &mut obj,
5896            use_x_scaling: &mut use_x,
5897            x_scaling: &mut x,
5898            use_g_scaling: &mut use_g,
5899            g_scaling: &mut g,
5900        });
5901        (ok, obj, use_x, x, use_g, g)
5902    }
5903
5904    /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
5905    /// the objective (`S6`) and one constraint (`S5`) reaches the
5906    /// engine's `user-scaling` pathway. The untagged second row is
5907    /// unscaled — its AMPL suffix default is 0, which is not a usable
5908    /// scale factor and reads as "not tagged".
5909    #[test]
5910    fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
5911        let nl = WITH_CON_SUFFIX.to_string()
5912            + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
5913        let p = parse_nl_text(&nl).expect("parse");
5914        let mut tnlp = NlTnlp::new(p);
5915        let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
5916        assert!(ok, "a tagged model must supply scaling");
5917        assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
5918        assert!(use_g);
5919        assert_eq!(g, vec![10.0, 1.0]);
5920        assert!(!use_x, "no variable suffix was declared");
5921    }
5922
5923    /// Variable-level `scaling_factor` entries are passed through, not
5924    /// dropped on the floor: `OrigIpoptNlp` does not model them and
5925    /// refuses the solve, which is the whole point of gh#483.
5926    #[test]
5927    fn scaling_factor_suffix_forwards_variable_factors() {
5928        let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
5929        let p = parse_nl_text(&nl).expect("parse");
5930        let mut tnlp = NlTnlp::new(p);
5931        let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
5932        assert!(ok);
5933        assert!(use_x, "variable factors must reach the engine");
5934        assert_eq!(x, vec![1.0, 3.0]);
5935    }
5936
5937    /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
5938    /// same answer the default `TNLP` impl gives, so `user-scaling`
5939    /// falls back to no scaling instead of a bogus all-zero vector.
5940    #[test]
5941    fn no_scaling_factor_suffix_declines() {
5942        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
5943        let mut tnlp = NlTnlp::new(p);
5944        let (ok, ..) = scaling_of(&mut tnlp);
5945        assert!(!ok);
5946    }
5947
5948    #[test]
5949    fn rejects_suffix_with_out_of_range_index() {
5950        let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
5951        let err = parse_nl_text(&bad).expect_err("must reject");
5952        assert!(
5953            err.contains("out of range"),
5954            "expected out-of-range error, got: {err}"
5955        );
5956    }
5957
5958    #[test]
5959    fn tnlp_round_trip_solves() {
5960        let p = parse_nl_text(SIMPLE).expect("parse");
5961        let mut tnlp = NlTnlp::new(p);
5962        let info = tnlp.get_nlp_info().unwrap();
5963        assert_eq!(info.n, 2);
5964        assert_eq!(info.m, 0);
5965        let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
5966        assert!((f0 - 5.0).abs() < 1e-12);
5967        let mut g = [0.0_f64; 2];
5968        tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
5969        // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
5970        assert!((g[0] - (-2.0)).abs() < 1e-12);
5971        assert!((g[1] - (-4.0)).abs() < 1e-12);
5972    }
5973
5974    // ---- Sibling `.col` / `.row` name-file capture --------------------
5975    //
5976    // Names let diagnostics name the offending equation instead of "row 3"
5977    // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
5978    // the read path and the documented fallback-to-empty behavior.
5979
5980    use pounce_nlp::expression_provider::ExpressionProvider;
5981    use std::sync::atomic::{AtomicUsize, Ordering};
5982
5983    /// Unique scratch dir for one test (no `tempfile` dev-dep available).
5984    fn scratch_dir(tag: &str) -> std::path::PathBuf {
5985        static N: AtomicUsize = AtomicUsize::new(0);
5986        let seq = N.fetch_add(1, Ordering::Relaxed);
5987        let dir = std::env::temp_dir().join(format!(
5988            "pounce_nlnames_{}_{}_{}",
5989            std::process::id(),
5990            tag,
5991            seq
5992        ));
5993        std::fs::create_dir_all(&dir).expect("create scratch dir");
5994        dir
5995    }
5996
5997    #[test]
5998    fn read_name_file_reads_in_order() {
5999        let dir = scratch_dir("col_order");
6000        let p = dir.join("m.col");
6001        std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
6002        assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
6003    }
6004
6005    #[test]
6006    fn read_name_file_truncates_extra_lines() {
6007        // `.row` conventionally appends the objective name after the m
6008        // constraint names; `.take(expected)` must drop it so names stay
6009        // 1:1 with `g`.
6010        let dir = scratch_dir("row_obj");
6011        let p = dir.join("m.row");
6012        std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
6013        assert_eq!(
6014            read_name_file(&p, 2),
6015            vec!["mass_balance", "energy_balance"]
6016        );
6017    }
6018
6019    #[test]
6020    fn read_name_file_empty_on_short_or_missing() {
6021        let dir = scratch_dir("short");
6022        let short = dir.join("m.col");
6023        std::fs::write(&short, "only_one\n").unwrap();
6024        // Fewer lines than expected ⇒ empty (never a partial mapping).
6025        assert!(read_name_file(&short, 3).is_empty());
6026        // Missing file ⇒ empty, no error.
6027        assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
6028    }
6029
6030    #[test]
6031    fn read_nl_file_captures_sibling_names() {
6032        // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
6033        // names ride through onto the TNLP's ExpressionProvider.
6034        let dir = scratch_dir("sibling");
6035        let nl = dir.join("m.nl");
6036        std::fs::write(&nl, SIMPLE).unwrap();
6037        std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
6038
6039        let prob = read_nl_file(&nl).expect("parse + name capture");
6040        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
6041        assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
6042
6043        let tnlp = NlTnlp::new(prob);
6044        assert_eq!(tnlp.variable_name(0), Some("alpha"));
6045        assert_eq!(tnlp.variable_name(1), Some("beta"));
6046        assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
6047    }
6048
6049    #[test]
6050    fn read_nl_file_without_names_yields_empty() {
6051        let dir = scratch_dir("noname");
6052        let nl = dir.join("m.nl");
6053        std::fs::write(&nl, SIMPLE).unwrap();
6054        let prob = read_nl_file(&nl).expect("parse");
6055        assert!(prob.var_names.is_empty());
6056        assert!(prob.con_names.is_empty());
6057        let tnlp = NlTnlp::new(prob);
6058        assert_eq!(tnlp.variable_name(0), None);
6059    }
6060
6061    #[test]
6062    fn read_nl_file_resolves_extensionless_ampl_stub() {
6063        // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
6064        // the `.nl` extension; the solver must read `mystub.nl`. Code
6065        // review 2026-06 item M15.
6066        let dir = scratch_dir("stub");
6067        std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
6068        // Pass the extensionless stub — the file `mystub` does not exist.
6069        let stub = dir.join("mystub");
6070        assert!(!stub.exists(), "stub must be extensionless / absent");
6071        let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
6072        assert_eq!(prob.n, 2);
6073        assert_eq!(prob.m, 0);
6074
6075        // Sibling name files are still found off the resolved stem.
6076        std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
6077        let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
6078        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
6079    }
6080
6081    #[test]
6082    fn read_nl_file_prefers_exact_path_over_nl_sibling() {
6083        // An existing path is read verbatim — the `.nl` fallback only
6084        // kicks in when the literal path is missing, so a caller passing a
6085        // real file is never silently redirected to a `<file>.nl` sibling.
6086        let dir = scratch_dir("exact");
6087        // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
6088        std::fs::write(dir.join("data"), SIMPLE).unwrap();
6089        std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
6090        let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
6091        assert_eq!(prob.n, 2);
6092    }
6093
6094    #[test]
6095    fn append_extension_appends_rather_than_replaces() {
6096        use std::path::Path;
6097        assert_eq!(
6098            append_extension(Path::new("mystub"), "nl"),
6099            Path::new("mystub.nl")
6100        );
6101        // A stub that itself contains a dot keeps its stem (AMPL names it
6102        // `my.model.nl`, not `my.nl`).
6103        assert_eq!(
6104            append_extension(Path::new("my.model"), "nl"),
6105            Path::new("my.model.nl")
6106        );
6107    }
6108
6109    // ---- equation rendering (`print equation`) ----
6110
6111    fn names(v: &[&str]) -> Vec<String> {
6112        v.iter().map(|s| s.to_string()).collect()
6113    }
6114
6115    #[test]
6116    fn render_uses_variable_names_when_present() {
6117        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
6118        assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
6119        // Falls back to x[i] when names are absent.
6120        assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
6121    }
6122
6123    #[test]
6124    fn render_parenthesizes_by_precedence() {
6125        // (x0 + x1) * x2  must keep the parens around the sum.
6126        let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
6127        let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
6128        assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
6129
6130        // x0 + x1 * x2  needs no parens (mul binds tighter).
6131        let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
6132        let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
6133        assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
6134    }
6135
6136    #[test]
6137    fn render_subtraction_right_assoc_parens() {
6138        // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
6139        let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
6140        let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
6141        assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
6142    }
6143
6144    #[test]
6145    fn render_functions_and_pow() {
6146        let sq = Expr::Binary(
6147            BinOp::Pow,
6148            Box::new(Expr::Var(0)),
6149            Box::new(Expr::Const(2.0)),
6150        );
6151        let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
6152        assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
6153    }
6154
6155    #[test]
6156    fn render_linear_signs_are_tidy() {
6157        // 1*a - 2*b + c  (coef +1 omits the multiplier).
6158        let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
6159        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
6160    }
6161
6162    #[test]
6163    fn render_linear_skips_zero_coefficients() {
6164        // A 0 coefficient (a variable present only in the nonlinear part)
6165        // is dropped, not rendered as `0*x`.
6166        let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
6167        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
6168        // Leading term zero ⇒ the first emitted term still has no ` + `.
6169        let lin = vec![(0usize, 0.0), (1, 2.0)];
6170        assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
6171    }
6172
6173    #[test]
6174    fn render_sum_folds_negative_terms() {
6175        // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
6176        let sq = |i| {
6177            Expr::Binary(
6178                BinOp::Pow,
6179                Box::new(Expr::Var(i)),
6180                Box::new(Expr::Const(2.0)),
6181            )
6182        };
6183        let neg = |i| {
6184            Expr::Binary(
6185                BinOp::Mul,
6186                Box::new(Expr::Const(-1.0)),
6187                Box::new(Expr::Var(i)),
6188            )
6189        };
6190        let e = Expr::Sum(vec![
6191            sq(0),
6192            neg(1),
6193            Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
6194        ]);
6195        assert_eq!(
6196            render_expr(&e, &names(&["a", "b", "c"]), &[]),
6197            "a^2 - 1*b - c"
6198        );
6199    }
6200
6201    #[test]
6202    fn render_constraint_equation_forms() {
6203        // Build a 2-constraint problem by hand: an equality and a range.
6204        let mut prob = parse_nl_text(SIMPLE).unwrap();
6205        // Overwrite to a known small shape: 1 var, 2 cons.
6206        prob.n = 2;
6207        prob.m = 2;
6208        prob.var_names = names(&["mass_in", "mass_out"]);
6209        prob.con_names = names(&["balance", "window"]);
6210        prob.con_linear = vec![
6211            vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
6212            vec![(0, 1.0)],            // mass_in
6213        ];
6214        prob.con_nonlinear = vec![Expr::Const(0.0), Expr::Const(0.0)];
6215        prob.g_l = vec![0.0, 0.0];
6216        prob.g_u = vec![0.0, 500.0];
6217
6218        assert_eq!(
6219            render_constraint_equation(&prob, 0),
6220            "mass_in - mass_out = 0"
6221        );
6222        assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
6223
6224        let all = render_all_constraint_equations(&prob);
6225        assert_eq!(all.len(), 2);
6226        assert_eq!(all[1], "0 <= mass_in <= 500");
6227    }
6228
6229    #[test]
6230    fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
6231        let mut prob = parse_nl_text(SIMPLE).unwrap();
6232        prob.n = 3;
6233        prob.m = 2;
6234        // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
6235        // Row 1: linear in x2 only → support {2}.
6236        prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
6237        prob.con_nonlinear = vec![
6238            Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(2))),
6239            Expr::Const(0.0),
6240        ];
6241        prob.g_l = vec![0.0, 0.0];
6242        prob.g_u = vec![0.0, 0.0];
6243
6244        let (irow, jcol) = constraint_jacobian_sparsity(&prob);
6245        // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
6246        assert_eq!(irow, vec![0, 0, 0, 1]);
6247        assert_eq!(jcol, vec![0, 1, 2, 2]);
6248    }
6249
6250    #[test]
6251    fn funcall_string_arg_with_hash_is_not_truncated() {
6252        // Code review L31: an AMPL string argument is a Hollerith literal
6253        // `h<len>:<chars>` whose content is exactly <len> bytes and may
6254        // legitimately contain '#' (e.g. a parameters-directory path). The
6255        // old parser ran strip_comment() over the line first, truncating
6256        // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
6257        let mut p = Parser::new("h3:a#b\n");
6258        match p.parse_funcall_arg().expect("parse hollerith arg") {
6259            FuncallArg::Str(s) => assert_eq!(s, "a#b"),
6260            other => panic!("expected Str, got {other:?}"),
6261        }
6262    }
6263
6264    #[test]
6265    fn funcall_string_arg_honors_declared_length() {
6266        // The declared `<len>` is authoritative: exactly that many bytes
6267        // after the ':' form the string; trailing content (here a real
6268        // ` # comment`) is not part of it.
6269        let mut p = Parser::new("h3:abc # trailing comment\n");
6270        match p.parse_funcall_arg().expect("parse hollerith arg") {
6271            FuncallArg::Str(s) => assert_eq!(s, "abc"),
6272            other => panic!("expected Str, got {other:?}"),
6273        }
6274    }
6275
6276    // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
6277    //
6278    // AMPL emits these in place of the general `o5` (OPPOW) when one operand
6279    // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
6280    // negative-base-safe constant-power lowering applies. The eval points below
6281    // are chosen to pin down BOTH the arity and the operand order: a swapped
6282    // `base`/`exp` (or treating `o82` as a different unary op) gives a
6283    // different number at these points, so each assertion is discriminating.
6284
6285    /// Parse a single expression `expr_src` with `n` variables in scope,
6286    /// driving the real `parse_opcode` path through `parse_expr`.
6287    fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
6288        let mut p = Parser::new(expr_src);
6289        p.n = n;
6290        p.parse_expr().expect("parse expression")
6291    }
6292
6293    #[test]
6294    fn opcode_o82_square_is_unary_pow_of_two() {
6295        // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
6296        let e = parse_one_expr(1, "o82\nv0\n");
6297        match &e {
6298            Expr::Binary(BinOp::Pow, base, exp) => {
6299                assert!(matches!(**base, Expr::Var(0)));
6300                match **exp {
6301                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
6302                    ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
6303                }
6304            }
6305            other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
6306        }
6307        // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
6308        // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
6309        assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
6310        assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
6311        // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
6312        let mut g = [0.0_f64; 1];
6313        grad_expr(&e, &[3.0], 1.0, &mut g);
6314        assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
6315        g[0] = 0.0;
6316        grad_expr(&e, &[-3.0], 1.0, &mut g);
6317        assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
6318    }
6319
6320    #[test]
6321    fn opcode_o81_const_exponent_is_base_pow_const() {
6322        // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
6323        let e = parse_one_expr(1, "o81\nv0\nn3\n");
6324        match &e {
6325            Expr::Binary(BinOp::Pow, base, exp) => {
6326                assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
6327                match **exp {
6328                    Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
6329                    ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
6330                }
6331            }
6332            other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
6333        }
6334        // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
6335        assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
6336        // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
6337        // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
6338        assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
6339        // gradient d/dx x^3 = 3x^2 = 12 at x=2.
6340        let mut g = [0.0_f64; 1];
6341        grad_expr(&e, &[2.0], 1.0, &mut g);
6342        assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
6343    }
6344
6345    #[test]
6346    fn opcode_o83_const_base_is_const_pow_exp() {
6347        // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
6348        let e = parse_one_expr(1, "o83\nn2\nv0\n");
6349        match &e {
6350            Expr::Binary(BinOp::Pow, base, exp) => {
6351                match **base {
6352                    Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
6353                    ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
6354                }
6355                assert!(
6356                    matches!(**exp, Expr::Var(0)),
6357                    "exponent must be the variable"
6358                );
6359            }
6360            other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
6361        }
6362        // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
6363        assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
6364        assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
6365        // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
6366        let mut g = [0.0_f64; 1];
6367        grad_expr(&e, &[3.0], 1.0, &mut g);
6368        assert!(
6369            (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
6370            "grad at 3 = {} (want {})",
6371            g[0],
6372            8.0 * 2.0_f64.ln()
6373        );
6374    }
6375
6376    #[test]
6377    fn power_specializations_agree_with_general_o5() {
6378        // Where both are defined, o81/o82/o83 must be numerically identical to
6379        // the general `o5` pow on the same operands — they are only routing
6380        // hints, not different math.
6381        let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
6382        let o82 = parse_one_expr(1, "o82\nv0\n");
6383        let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
6384        let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
6385        let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
6386        let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
6387        for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
6388            assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
6389            assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
6390            // 2^x is real for all x; compare across the same points.
6391            assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
6392        }
6393    }
6394
6395    #[test]
6396    fn power_opcodes_round_trip_through_parse_nl_text() {
6397        // End-to-end through the public entry point: `min x0^2 + x1^2` written
6398        // with o82 (square) parses and evaluates like its o5 twin. Reuses the
6399        // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
6400        let nl = SIMPLE.replace(
6401            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
6402            "o0\no82\nv0\no82\nv1\n",
6403        );
6404        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
6405        let p = parse_nl_text(&nl).expect("parse o82 objective");
6406        // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
6407        assert!((eval_expr(&p.obj_nonlinear, &[3.0, 4.0]) - 25.0).abs() < 1e-12);
6408        assert!((eval_expr(&p.obj_nonlinear, &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
6409    }
6410
6411    #[test]
6412    fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
6413        // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
6414        // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
6415        // (the negative-base-safe path) rather than a generic `powf`. The check
6416        // at a NEGATIVE base is the one that would break if o81 wrongly routed
6417        // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
6418        let nl = SIMPLE.replace(
6419            "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
6420            "o0\no81\nv0\nn3\no81\nv1\nn3\n",
6421        );
6422        assert_ne!(nl, SIMPLE, "fixture substitution must apply");
6423        let p = parse_nl_text(&nl).expect("parse o81 objective");
6424        let mut tnlp = NlTnlp::new(p);
6425        tnlp.get_nlp_info().unwrap();
6426        // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
6427        let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
6428        assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
6429        // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
6430        let mut g = [0.0_f64; 2];
6431        assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
6432        assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
6433        assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
6434    }
6435
6436    // ---- Shared-CSE constraint tape (issue #476) ----------------------
6437
6438    /// Three constraints over one `V` segment (a `.nl` *defined variable*),
6439    /// `V3 = 2*x0 + 3*x1`, referenced by all three:
6440    ///   C0: V3^2        C1: V3^3 + x2        C2: V3*x2
6441    /// `{BODY2}` is a substitution point so a variant can drop an opcode the
6442    /// hybrid path rejects into C2.
6443    const SHARED_CSE: &str = "g3 1 1 0
6444 3 3 1 0 0
6445 3 0
6446 0 0
6447 3 0 0
6448 0 0 0 1
6449 0 0 0 0 0
6450 8 3
6451 0 0
6452 0 1 0 0 0
6453V3 2 0
64540 2.0
64551 3.0
6456n0
6457C0
6458o5
6459v3
6460n2
6461C1
6462o0
6463o5
6464v3
6465n3
6466v2
6467C2
6468{BODY2}
6469O0 0
6470n0
6471r
64722 0
64732 0
64742 0
6475b
64763
64773
64783
6479k2
64803
64816
6482J0 2
64830 0
64841 0
6485J1 3
64860 0
64871 0
64882 0
6489J2 3
64900 0
64911 0
64922 0
6493G0 3
64940 1.0
64951 1.0
64962 1.0
6497";
6498
6499    fn shared_cse_nl(body2: &str) -> String {
6500        SHARED_CSE.replace("{BODY2}", body2)
6501    }
6502
6503    /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
6504    /// per-summand tapes return — it is a different traversal of the same
6505    /// DAG, not a different derivative. Forced on here regardless of
6506    /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
6507    /// the size heuristic that decides when to use it.
6508    #[test]
6509    fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
6510        let nl = shared_cse_nl("o2\nv3\nv2");
6511        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
6512
6513        let mut hybrid = NlTnlp::new(p.clone());
6514        let info = hybrid.get_nlp_info().unwrap();
6515        let nnz = info.nnz_jac_g as usize;
6516        hybrid
6517            .con_hybrid
6518            .as_mut()
6519            .expect("CSE shared by 3 constraints must build the hybrid tape")
6520            .use_for_jac = true;
6521
6522        let mut flat = NlTnlp::new(p);
6523        flat.get_nlp_info().unwrap();
6524        flat.con_hybrid = None;
6525
6526        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
6527            let mut jh = vec![0.0_f64; nnz];
6528            let mut jf = vec![0.0_f64; nnz];
6529            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
6530            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
6531            assert_eq!(
6532                jh, jf,
6533                "hybrid Jacobian differs from the flat tape at {x:?}"
6534            );
6535
6536            // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
6537            let s = 2.0 * x[0] + 3.0 * x[1];
6538            let want = [
6539                4.0 * s,
6540                6.0 * s,
6541                6.0 * s * s,
6542                9.0 * s * s,
6543                1.0,
6544                2.0 * x[2],
6545                3.0 * x[2],
6546                s,
6547            ];
6548            assert_eq!(nnz, want.len());
6549            for k in 0..nnz {
6550                assert!(
6551                    (jh[k] - want[k]).abs() < 1e-9,
6552                    "entry {k} at {x:?}: got {}, want {}",
6553                    jh[k],
6554                    want[k]
6555                );
6556            }
6557        }
6558    }
6559
6560    /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
6561    /// CSE `S = body(x0, x1)` (the caller passes the body's expression
6562    /// text, which must reference exactly `v0` and `v1`). A deep shared
6563    /// body over few local ops per row is the regime the op-ratio gates
6564    /// are meant to catch: no repository fixture has a CSE shared across
6565    /// constraints at all, so without this the gate-on paths have no
6566    /// natural coverage.
6567    fn shared_body_chain_nl(m: usize, body: &str) -> String {
6568        let n = m + 2;
6569        let nzc = 3 * m;
6570        let mut s = String::new();
6571        s.push_str("g3 1 1 0\n");
6572        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
6573        s.push_str(&format!(" {m} 0\n 0 0\n"));
6574        s.push_str(&format!(" {n} 0 0\n"));
6575        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
6576        s.push_str(&format!(" {nzc} {n}\n"));
6577        s.push_str(" 0 0\n 0 1 0 0 0\n");
6578        // The shared body.
6579        s.push_str(&format!("V{n} 0 0\n"));
6580        s.push_str(body);
6581        // Rows: S * x_{i+2}.
6582        for i in 0..m {
6583            s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
6584        }
6585        s.push_str("O0 0\nn0\n");
6586        s.push_str(&format!("x{n}\n"));
6587        for j in 0..n {
6588            s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
6589        }
6590        s.push_str("r\n");
6591        for _ in 0..m {
6592            s.push_str("2 0\n");
6593        }
6594        s.push_str("b\n");
6595        for _ in 0..n {
6596            s.push_str("3\n");
6597        }
6598        // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
6599        s.push_str(&format!("k{}\n", n - 1));
6600        let mut acc = 0;
6601        for j in 0..n - 1 {
6602            acc += if j < 2 { m } else { 1 };
6603            s.push_str(&format!("{acc}\n"));
6604        }
6605        for i in 0..m {
6606            s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
6607        }
6608        s.push_str(&format!("G0 {n}\n"));
6609        for j in 0..n {
6610            s.push_str(&format!("{j} 0.0\n"));
6611        }
6612        s
6613    }
6614
6615    /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
6616    /// The forward value saturates to `inf` past depth ≈ 5, which the
6617    /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
6618    /// bounded variant below instead, whose second-order terms would
6619    /// otherwise mix `inf`s of both signs into `NaN`.
6620    fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
6621        let mut body = String::new();
6622        for _ in 0..depth {
6623            body.push_str("o44\n");
6624        }
6625        body.push_str("o2\nn0.01\no0\nv0\nv1\n");
6626        shared_body_chain_nl(m, &body)
6627    }
6628
6629    /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
6630    /// (x0 + x1))` — mathematically the identity chain, so the value
6631    /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
6632    /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
6633    /// through both second-order sweeps. `2 * pairs` body ops drive the
6634    /// flat/shared op ratio as high as the Hessian gate tests need.
6635    fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
6636        let mut body = String::new();
6637        for _ in 0..pairs {
6638            body.push_str("o43\no44\n");
6639        }
6640        body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
6641        shared_body_chain_nl(m, &body)
6642    }
6643
6644    /// With a deep shared body the gate turns itself on, and the path it
6645    /// turns on must still agree with the flat tapes exactly.
6646    #[test]
6647    fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
6648        let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
6649
6650        let mut hybrid = NlTnlp::new(p.clone());
6651        let info = hybrid.get_nlp_info().unwrap();
6652        let nnz = info.nnz_jac_g as usize;
6653        assert!(
6654            hybrid
6655                .con_hybrid
6656                .as_ref()
6657                .expect("shared CSE must build the hybrid tape")
6658                .use_for_jac,
6659            "a 40-deep body shared by 16 rows is well past the op-ratio gate"
6660        );
6661
6662        let mut flat = NlTnlp::new(p);
6663        flat.get_nlp_info().unwrap();
6664        flat.con_hybrid = None;
6665
6666        for scale in [1.0_f64, -0.7, 2.5] {
6667            let x: Vec<f64> = (0..info.n as usize)
6668                .map(|j| scale * (0.2 + 0.03 * j as f64))
6669                .collect();
6670            let mut jh = vec![0.0_f64; nnz];
6671            let mut jf = vec![0.0_f64; nnz];
6672            assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
6673            assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
6674            assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
6675            assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
6676        }
6677    }
6678
6679    /// The Jacobian gate is off for a model whose shared bodies are small,
6680    /// because there the hybrid traversal's per-op overhead outweighs the
6681    /// halved forward sweep. `eval_g` still takes the hybrid path — that
6682    /// one is a win at any ratio.
6683    #[test]
6684    fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
6685        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
6686        let mut t = NlTnlp::new(p);
6687        t.get_nlp_info().unwrap();
6688        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
6689        assert!(
6690            !h.use_for_jac,
6691            "a 3-row model with a 2-term CSE is far below the op-ratio gate"
6692        );
6693    }
6694
6695    /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
6696    /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
6697    /// adjoints and running one prelude sweep reassociates floating-point
6698    /// products, so the two paths agree only to rounding on general inputs —
6699    /// but here every operation in both traversals is exact (dyadic values,
6700    /// small-integer coefficients, polynomial ops), so the results must be
6701    /// bit-identical, pinning the arithmetic itself and not just its
6702    /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
6703    /// path is covered independently of the size heuristic.
6704    #[test]
6705    fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
6706        let nl = shared_cse_nl("o2\nv3\nv2");
6707        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
6708
6709        let mut hybrid = NlTnlp::new(p.clone());
6710        let info = hybrid.get_nlp_info().unwrap();
6711        let nnz = info.nnz_h_lag as usize;
6712        hybrid
6713            .con_hybrid
6714            .as_mut()
6715            .expect("CSE shared by 3 constraints must build the hybrid tape")
6716            .use_for_hess = true;
6717
6718        let mut flat = NlTnlp::new(p);
6719        flat.get_nlp_info().unwrap();
6720        flat.con_hybrid = None;
6721
6722        let pairs: Vec<(usize, usize)> = hybrid
6723            .h_irow
6724            .iter()
6725            .zip(&hybrid.h_jcol)
6726            .map(|(&i, &j)| (i as usize, j as usize))
6727            .collect();
6728
6729        // Two multiplier sets: one all-live, one with a dead row so the
6730        // λ == 0 skip is exercised on the hybrid path too.
6731        for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
6732            for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
6733                let mut hh = vec![0.0_f64; nnz];
6734                let mut hf = vec![0.0_f64; nnz];
6735                assert!(hybrid.eval_h(
6736                    Some(&x),
6737                    true,
6738                    1.0,
6739                    Some(&lam),
6740                    true,
6741                    SparsityRequest::Values { values: &mut hh }
6742                ));
6743                assert!(flat.eval_h(
6744                    Some(&x),
6745                    true,
6746                    1.0,
6747                    Some(&lam),
6748                    true,
6749                    SparsityRequest::Values { values: &mut hf }
6750                ));
6751                assert_eq!(
6752                    hh, hf,
6753                    "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
6754                );
6755
6756                // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
6757                // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
6758                // objective is constant, so the Lagrangian Hessian is
6759                //   (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
6760                let s = 2.0 * x[0] + 3.0 * x[1];
6761                let q = 2.0 * lam[0] + 6.0 * s * lam[1];
6762                let dv = [2.0, 3.0, 0.0];
6763                for (k, &(i, j)) in pairs.iter().enumerate() {
6764                    let mut want = q * dv[i] * dv[j];
6765                    if i == 2 {
6766                        want += lam[2] * dv[j];
6767                    }
6768                    if j == 2 {
6769                        want += lam[2] * dv[i];
6770                    }
6771                    assert!(
6772                        (hh[k] - want).abs() < 1e-9,
6773                        "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
6774                        hh[k]
6775                    );
6776                }
6777            }
6778        }
6779    }
6780
6781    /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
6782    /// turns the Hessian gate on by itself, and the path it turns on must
6783    /// agree with the flat tapes. Not bitwise here: the shared prelude
6784    /// reverse sweep runs once over the `λ_k`-folded adjoints of all
6785    /// summands where the flat path runs per summand, and that
6786    /// reassociation moves transcendental results by rounding — so the bar
6787    /// is a relative few-ULP band, with the exact-arithmetic case pinned
6788    /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
6789    #[test]
6790    fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
6791        let m = 16;
6792        let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
6793
6794        let mut hybrid = NlTnlp::new(p.clone());
6795        let info = hybrid.get_nlp_info().unwrap();
6796        let nnz = info.nnz_h_lag as usize;
6797        assert!(
6798            hybrid
6799                .con_hybrid
6800                .as_ref()
6801                .expect("shared CSE must build the hybrid tape")
6802                .use_for_hess,
6803            "a 40-op body shared by 16 rows is well past the op-ratio gate"
6804        );
6805
6806        let mut flat = NlTnlp::new(p);
6807        flat.get_nlp_info().unwrap();
6808        flat.con_hybrid = None;
6809
6810        let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
6811        for scale in [1.0_f64, -0.7, 2.5] {
6812            let x: Vec<f64> = (0..info.n as usize)
6813                .map(|j| scale * (0.2 + 0.03 * j as f64))
6814                .collect();
6815            // Run the hybrid Jacobian first, the order a real solve
6816            // iteration uses. Its `gradient_summand` sweeps leave the
6817            // *Jacobian's* prelude adjoint arena dirty; the Hessian's
6818            // accumulators must be its own buffers with the all-zero-
6819            // between-colors invariant, or this seeds `eval_h` with a
6820            // stale row gradient (caught here).
6821            let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
6822            assert!(hybrid.eval_jac_g(
6823                Some(&x),
6824                true,
6825                SparsityRequest::Values { values: &mut jac }
6826            ));
6827            let mut hh = vec![0.0_f64; nnz];
6828            let mut hf = vec![0.0_f64; nnz];
6829            assert!(hybrid.eval_h(
6830                Some(&x),
6831                true,
6832                1.0,
6833                Some(&lam),
6834                true,
6835                SparsityRequest::Values { values: &mut hh }
6836            ));
6837            assert!(flat.eval_h(
6838                Some(&x),
6839                true,
6840                1.0,
6841                Some(&lam),
6842                true,
6843                SparsityRequest::Values { values: &mut hf }
6844            ));
6845            for k in 0..nnz {
6846                assert!(
6847                    hh[k].is_finite() && hf[k].is_finite(),
6848                    "non-finite Hessian entry {k} defeats the comparison"
6849                );
6850                let tol = 1e-12 * hf[k].abs().max(1.0);
6851                assert!(
6852                    (hh[k] - hf[k]).abs() <= tol,
6853                    "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
6854                    hh[k],
6855                    hf[k]
6856                );
6857            }
6858            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
6859        }
6860    }
6861
6862    /// `.nl` text for two independent shared-CSE blocks of different widths:
6863    /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
6864    /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
6865    /// of the form `body * x_r`.
6866    ///
6867    /// The width difference is the point. A body summing `w` variables gives
6868    /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
6869    /// conflict and the coloring must spend `w` colors on them; the narrower
6870    /// block reuses the low colors. The surplus colors therefore belong to
6871    /// block A alone, and a per-color prelude walk over *both* bodies would
6872    /// be doing work for a body that color cannot reach.
6873    fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
6874        let nvars = wide + narrow;
6875        let m = 2 * rows;
6876        let n = nvars + m;
6877        // Block A rows touch `wide` body vars + 1 row var; block B rows
6878        // touch `narrow` + 1.
6879        let nzc = rows * (wide + 1) + rows * (narrow + 1);
6880        let mut s = String::new();
6881        s.push_str("g3 1 1 0\n");
6882        s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
6883        s.push_str(&format!(" {m} 0\n 0 0\n"));
6884        s.push_str(&format!(" {n} 0 0\n"));
6885        s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
6886        s.push_str(&format!(" {nzc} {n}\n"));
6887        s.push_str(" 0 0\n 0 2 0 0 0\n");
6888        // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
6889        // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
6890        for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
6891            s.push_str(&format!("V{} 0 0\n", n + b));
6892            s.push_str("o41\n");
6893            for _ in 0..count - 1 {
6894                s.push_str("o0\n");
6895            }
6896            for j in 0..*count {
6897                s.push_str(&format!("v{}\n", base + j));
6898            }
6899        }
6900        // Rows: body_b * x_{rowvar}.
6901        for i in 0..m {
6902            let b = i / rows;
6903            s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
6904        }
6905        s.push_str("O0 0\nn0\n");
6906        s.push_str(&format!("x{n}\n"));
6907        for j in 0..n {
6908            s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
6909        }
6910        s.push_str("r\n");
6911        for _ in 0..m {
6912            s.push_str("2 0\n");
6913        }
6914        s.push_str("b\n");
6915        for _ in 0..n {
6916            s.push_str("3\n");
6917        }
6918        // Cumulative Jacobian column counts for the first n-1 columns.
6919        s.push_str(&format!("k{}\n", n - 1));
6920        let mut acc = 0;
6921        for j in 0..n - 1 {
6922            acc += if j < nvars { rows } else { 1 };
6923            s.push_str(&format!("{acc}\n"));
6924        }
6925        for i in 0..m {
6926            let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
6927            s.push_str(&format!("J{i} {}\n", count + 1));
6928            let mut cols: Vec<usize> = (base..base + count).collect();
6929            cols.push(nvars + i);
6930            cols.sort_unstable();
6931            for c in cols {
6932                s.push_str(&format!("{c} 0.0\n"));
6933            }
6934        }
6935        s.push_str(&format!("G0 {n}\n"));
6936        for j in 0..n {
6937            s.push_str(&format!("{j} 0.0\n"));
6938        }
6939        s
6940    }
6941
6942    /// Both prelude sweeps run once per color, so iterating the whole prelude
6943    /// each time would cost `n_colors × |prelude|` where the op-ratio gate
6944    /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
6945    /// `eval_h` instead walks the union of that color's summands'
6946    /// `prelude_reach`.
6947    ///
6948    /// What this can and cannot pin is worth being exact about, because the
6949    /// change is pure performance: walking the whole prelude per color
6950    /// computes the *same* Hessian, so no assertion on output values can
6951    /// detect it, and a timing assertion would be flaky. So this asserts the
6952    /// two things that are checkable — that the table the sweeps iterate is
6953    /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
6954    /// where colors genuinely reach different bodies, and that each reach list
6955    /// is ascending and operand-closed, the invariants that make the narrowed
6956    /// walk safe — plus agreement with the flat tapes under narrowing.
6957    #[test]
6958    fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
6959        let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
6960        let mut hybrid = NlTnlp::new(p.clone());
6961        let info = hybrid.get_nlp_info().unwrap();
6962        let nnz = info.nnz_h_lag as usize;
6963        let m = info.m as usize;
6964
6965        {
6966            let h = hybrid
6967                .con_hybrid
6968                .as_mut()
6969                .expect("two shared CSE bodies must build the hybrid tape");
6970            h.use_for_hess = true;
6971
6972            let np = h.tape.n_prelude_ops();
6973            let n_colors = h.hess_color_reach_off.len() - 1;
6974            let total: usize = h.hess_color_reach.len();
6975            assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
6976            assert!(
6977                total < n_colors * np,
6978                "per-color reach must be strictly smaller than walking the whole \
6979                 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
6980                n_colors * np
6981            );
6982            // Every reach list must be ascending and operand-closed, which is
6983            // what makes the narrowed walk safe.
6984            for c in 0..n_colors {
6985                let r =
6986                    &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
6987                assert!(
6988                    r.windows(2).all(|w| w[0] < w[1]),
6989                    "color {c} reach is not strictly ascending"
6990                );
6991                let member: std::collections::HashSet<u32> = r.iter().copied().collect();
6992                for &i in r {
6993                    let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
6994                    for opnd in [a, b].into_iter().flatten() {
6995                        assert!(
6996                            member.contains(&(opnd as u32)),
6997                            "color {c}: slot {i}'s operand {opnd} is missing from its reach"
6998                        );
6999                    }
7000                }
7001            }
7002        }
7003
7004        let mut flat = NlTnlp::new(p);
7005        flat.get_nlp_info().unwrap();
7006        flat.con_hybrid = None;
7007
7008        let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
7009        for scale in [1.0_f64, -0.6] {
7010            let x: Vec<f64> = (0..info.n as usize)
7011                .map(|j| scale * (0.15 + 0.02 * j as f64))
7012                .collect();
7013            let mut hh = vec![0.0_f64; nnz];
7014            let mut hf = vec![0.0_f64; nnz];
7015            assert!(hybrid.eval_h(
7016                Some(&x),
7017                true,
7018                1.0,
7019                Some(&lam),
7020                true,
7021                SparsityRequest::Values { values: &mut hh }
7022            ));
7023            assert!(flat.eval_h(
7024                Some(&x),
7025                true,
7026                1.0,
7027                Some(&lam),
7028                true,
7029                SparsityRequest::Values { values: &mut hf }
7030            ));
7031            for k in 0..nnz {
7032                let tol = 1e-12 * hf[k].abs().max(1.0);
7033                assert!(
7034                    (hh[k] - hf[k]).abs() <= tol,
7035                    "narrowed-reach Hessian entry {k} at scale {scale}: \
7036                     hybrid {} vs flat {}",
7037                    hh[k],
7038                    hf[k]
7039                );
7040            }
7041            assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
7042        }
7043    }
7044
7045    /// The Hessian gate is off for a model whose shared bodies are small —
7046    /// below the ratio where the shared prelude sweeps pay for the hybrid
7047    /// traversal's per-op overhead — leaving `eval_h` on the flat path
7048    /// (which the gate keeps bit-identical for such models by definition).
7049    #[test]
7050    fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
7051        let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
7052        let mut t = NlTnlp::new(p);
7053        t.get_nlp_info().unwrap();
7054        let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
7055        assert!(
7056            !h.use_for_hess,
7057            "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
7058        );
7059    }
7060
7061    /// A CSE referenced from several constraints is evaluated once per
7062    /// `eval_g` via the shared prelude instead of once per reference. The
7063    /// values must be bit-identical to the flat per-summand `Tape` path,
7064    /// which is what makes the optimization safe to apply unconditionally.
7065    #[test]
7066    fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
7067        let nl = shared_cse_nl("o2\nv3\nv2");
7068        let p = parse_nl_text(&nl).expect("parse shared-CSE model");
7069        let mut hybrid = NlTnlp::new(p.clone());
7070        hybrid.get_nlp_info().unwrap();
7071        let h = hybrid
7072            .con_hybrid
7073            .as_ref()
7074            .expect("CSE shared by 3 constraints must take the hybrid path");
7075        assert!(
7076            h.tape.n_prelude_ops() > 0,
7077            "shared CSE body must land in the prelude"
7078        );
7079
7080        // Same model with the hybrid path switched off: the reference.
7081        let mut flat = NlTnlp::new(p);
7082        flat.get_nlp_info().unwrap();
7083        flat.con_hybrid = None;
7084
7085        for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
7086            let mut gh = [0.0_f64; 3];
7087            let mut gf = [0.0_f64; 3];
7088            assert!(hybrid.eval_g(&x, true, &mut gh));
7089            assert!(flat.eval_g(&x, true, &mut gf));
7090            // V3 = 2 x0 + 3 x1.
7091            let s = 2.0 * x[0] + 3.0 * x[1];
7092            let want = [s * s, s * s * s + x[2], s * x[2]];
7093            for i in 0..3 {
7094                assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
7095                assert!(
7096                    (gh[i] - want[i]).abs() < 1e-9,
7097                    "row {i}: got {}, want {}",
7098                    gh[i],
7099                    want[i]
7100                );
7101            }
7102        }
7103    }
7104
7105    /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
7106    /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
7107    /// take that path after `hybrid_supported` clears the model. Here a
7108    /// min-list in one constraint has to disable it for the whole block —
7109    /// falling back, not panicking.
7110    #[test]
7111    fn unsupported_opcode_falls_back_to_the_flat_tape() {
7112        let nl = shared_cse_nl("o11\n2\nv3\nv2");
7113        let p = parse_nl_text(&nl).expect("parse min-list model");
7114        let mut tnlp = NlTnlp::new(p);
7115        tnlp.get_nlp_info().unwrap();
7116        assert!(
7117            tnlp.con_hybrid.is_none(),
7118            "a min-list anywhere in the constraint block must disable the hybrid path"
7119        );
7120        let mut g = [0.0_f64; 3];
7121        assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
7122        let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
7123        assert!((g[0] - s * s).abs() < 1e-9);
7124        assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
7125    }
7126
7127    // ---- In-memory construction + HVP (issue #469) --------------------
7128
7129    fn v(i: usize) -> Expr {
7130        Expr::Var(i)
7131    }
7132
7133    fn c(x: Number) -> Expr {
7134        Expr::Const(x)
7135    }
7136
7137    fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
7138        Expr::Binary(op, Box::new(a), Box::new(b))
7139    }
7140
7141    fn un(op: UnaryOp, a: Expr) -> Expr {
7142        Expr::Unary(op, Box::new(a))
7143    }
7144
7145    /// `NlProblemParts` for an `n`-variable, unbounded model.
7146    fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
7147        let m = constraints.len();
7148        NlProblemParts {
7149            minimize: true,
7150            objective,
7151            obj_constant: 0.0,
7152            constraints,
7153            x_l: vec![-1e19; n],
7154            x_u: vec![1e19; n],
7155            x0: vec![0.0; n],
7156            g_l: vec![-1e19; m],
7157            g_u: vec![1e19; m],
7158            var_names: Vec::new(),
7159            con_names: Vec::new(),
7160        }
7161    }
7162
7163    /// A model built from expressions evaluates exactly like a parsed one:
7164    /// objective, gradient, constraints, and Jacobian all come from the
7165    /// same tape, with no `.nl` text in the loop.
7166    #[test]
7167    fn from_expressions_builds_evaluable_problem() {
7168        // min (1-x0)^2 + 100*(x1 - x0^2)^2   s.t.  x0^2 + x1^2 <= 2
7169        let rosen = bin(
7170            BinOp::Add,
7171            bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
7172            bin(
7173                BinOp::Mul,
7174                c(100.0),
7175                bin(
7176                    BinOp::Pow,
7177                    bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
7178                    c(2.0),
7179                ),
7180            ),
7181        );
7182        let circle = bin(
7183            BinOp::Add,
7184            bin(BinOp::Pow, v(0), c(2.0)),
7185            bin(BinOp::Pow, v(1), c(2.0)),
7186        );
7187
7188        let mut p = parts(2, rosen, vec![circle]);
7189        p.g_l = vec![0.0];
7190        p.g_u = vec![2.0];
7191        p.x0 = vec![-1.2, 1.0];
7192        p.var_names = names(&["x", "y"]);
7193        p.con_names = names(&["circle"]);
7194
7195        let prob = NlProblem::from_expressions(p).expect("build");
7196        assert_eq!((prob.n, prob.m), (2, 1));
7197        assert_eq!(prob.var_names, names(&["x", "y"]));
7198
7199        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7200        t.get_nlp_info().unwrap();
7201
7202        // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
7203        let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
7204        assert!((f - 24.2).abs() < 1e-10, "f = {f}");
7205
7206        // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
7207        //    = (4.4 + 480*(-0.44)... ) — computed below rather than
7208        //      transcribed, so the check is the formula, not an editor.
7209        let (x0, x1) = (-1.2, 1.0);
7210        let want = [
7211            -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
7212            200.0 * (x1 - x0 * x0),
7213        ];
7214        let mut g = [0.0_f64; 2];
7215        assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
7216        for j in 0..2 {
7217            assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
7218        }
7219
7220        // g(x) = x0^2 + x1^2 = 2.44
7221        let mut gv = [0.0_f64; 1];
7222        assert!(t.eval_g(&[x0, x1], true, &mut gv));
7223        assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
7224    }
7225
7226    /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
7227    /// this path. None of the three survives a `.nl` round trip in a
7228    /// typical frontend — `atan2` has no two-argument funcall path,
7229    /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
7230    /// at all — which is the reason the in-memory door exists.
7231    #[test]
7232    fn from_expressions_carries_ops_nl_cannot_express() {
7233        let obj = Expr::Sum(vec![
7234            bin(BinOp::Atan2, v(0), v(1)),
7235            Expr::MinList(vec![v(0), v(1)]),
7236            Expr::MaxList(vec![v(0), v(1)]),
7237            un(UnaryOp::Erf, v(0)),
7238        ]);
7239        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
7240        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7241        t.get_nlp_info().unwrap();
7242
7243        let x: [Number; 2] = [0.8, 1.5];
7244        // atan2 + min + max + erf; min+max == x0+x1 for any pair.
7245        let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
7246        let f = t.eval_f(&x, true).unwrap();
7247        assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
7248    }
7249
7250    /// A `Var` index past `n` would be an out-of-bounds read in the
7251    /// tape's forward sweep. It has to be caught at construction, while
7252    /// it is still a diagnosable user error.
7253    #[test]
7254    fn from_expressions_rejects_out_of_range_var() {
7255        let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
7256            .expect_err("Var(5) with n = 2 must be rejected");
7257        assert!(err.contains("Var(5)"), "{err}");
7258
7259        let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
7260            .expect_err("constraint Var(2) with n = 2 must be rejected");
7261        assert!(err.contains("constraint 0"), "{err}");
7262
7263        // Length mismatches are errors too, not panics.
7264        let mut p = parts(2, c(0.0), Vec::new());
7265        p.x0 = vec![0.0; 3];
7266        let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
7267        assert!(err.contains("x0"), "{err}");
7268    }
7269
7270    /// An out-of-range `Var` must be caught wherever it hides, not just at
7271    /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
7272    /// branch, and a funcall argument all reach the same forward sweep.
7273    #[test]
7274    fn from_expressions_finds_out_of_range_vars_in_every_position() {
7275        let inner_cse = Arc::new(v(7));
7276        let cases: Vec<(&str, Expr)> = vec![
7277            ("bare", v(7)),
7278            ("cse", Expr::Cse(Arc::new(v(7)))),
7279            ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
7280            (
7281                "cond branch",
7282                Expr::Cond {
7283                    cond: Box::new(c(1.0)),
7284                    then_: Box::new(v(7)),
7285                    else_: Box::new(c(0.0)),
7286                },
7287            ),
7288            ("min list", Expr::MinList(vec![c(0.0), v(7)])),
7289            (
7290                "sum",
7291                Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
7292            ),
7293        ];
7294        for (label, e) in cases {
7295            let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
7296                .err()
7297                .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
7298            assert!(err.contains("Var(7)"), "{label}: {err}");
7299        }
7300    }
7301
7302    /// A balanced share-DAG: each level is one `Cse` whose body
7303    /// references the level below twice. Depth `d` is `d` distinct nodes
7304    /// but `2^d` paths, so any walk that re-enters a shared body per
7305    /// occurrence is Θ(2^d).
7306    fn share_dag(depth: usize) -> Expr {
7307        let mut e = v(0);
7308        for _ in 0..depth {
7309            let shared = Arc::new(e);
7310            e = bin(
7311                BinOp::Add,
7312                Expr::Cse(Arc::clone(&shared)),
7313                Expr::Cse(shared),
7314            );
7315        }
7316        e
7317    }
7318
7319    /// The walks over a shared DAG must be memoized, not exponential.
7320    ///
7321    /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
7322    /// does not "fail" so much as never finish, which is exactly the
7323    /// signal. `from_expressions` is the door that makes such a DAG
7324    /// trivially constructible, but the blowup was reachable through
7325    /// `collect_vars` (which presolve calls on every solve, via
7326    /// `get_variables_linearity`) and `collect_funcall_ids` (which
7327    /// `NlTnlp::try_new` runs over every row).
7328    #[test]
7329    fn shared_dag_walks_are_memoized_not_exponential() {
7330        const DEPTH: usize = 30;
7331        let e = share_dag(DEPTH);
7332
7333        let mut vars = BTreeSet::new();
7334        collect_vars(&e, &mut vars);
7335        assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
7336
7337        let mut ids = BTreeSet::new();
7338        super::super::nl_external::collect_funcall_ids(&e, &mut ids);
7339        assert!(ids.is_empty());
7340
7341        // The whole build path, end to end: validation, tape construction,
7342        // and the linearity metadata presolve consumes.
7343        let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
7344        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7345        t.get_nlp_info().unwrap();
7346        let mut lin = vec![Linearity::Linear; 1];
7347        assert!(t.get_variables_linearity(&mut lin));
7348    }
7349
7350    /// `from_expressions` cannot carry AMPL imported functions — there is
7351    /// nowhere to put the `F`-segment declarations that bind a funcall id
7352    /// to a library — so a `Funcall` must be refused up front. Accepting it
7353    /// produces "AMPLFUNC is not set", which the user cannot act on:
7354    /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
7355    #[test]
7356    fn from_expressions_rejects_imported_function_calls() {
7357        let call = Expr::Funcall {
7358            id: 0,
7359            args: vec![FuncallArg::Real(v(0))],
7360        };
7361        let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
7362            .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
7363        assert!(err.contains("imported function"), "{err}");
7364        assert!(
7365            err.contains("read_nl") || err.contains("parse_nl_text"),
7366            "the error must point at the paths that do support externals: {err}"
7367        );
7368
7369        // Also when buried in a constraint, behind a Cse.
7370        let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
7371        let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
7372            .expect_err("a buried Funcall must be rejected too");
7373        assert!(err.contains("constraint 0"), "{err}");
7374    }
7375
7376    /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
7377    /// same tapes, same weights, one seed instead of a color sweep. The
7378    /// objective and both constraints are chosen with cross terms so the
7379    /// off-diagonal blocks actually carry signal.
7380    #[test]
7381    fn hessian_vector_product_matches_dense_hessian() {
7382        let obj = Expr::Sum(vec![
7383            bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
7384            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
7385            un(UnaryOp::Erf, v(2)),
7386        ]);
7387        let cons = vec![
7388            bin(
7389                BinOp::Add,
7390                bin(BinOp::Pow, v(0), c(2.0)),
7391                un(UnaryOp::Sin, v(2)),
7392            ),
7393            bin(BinOp::Mul, v(1), v(2)),
7394        ];
7395        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
7396        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7397        let info = t.get_nlp_info().unwrap();
7398
7399        let x = [0.3, -0.7, 1.1];
7400        let lam = [0.5, -1.25];
7401        let obj_factor = 2.0;
7402
7403        // Dense Hessian from the sparse lower triangle.
7404        let nnz = info.nnz_h_lag as usize;
7405        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
7406        assert!(t.eval_h(
7407            None,
7408            false,
7409            1.0,
7410            None,
7411            false,
7412            SparsityRequest::Structure {
7413                irow: &mut irow,
7414                jcol: &mut jcol
7415            }
7416        ));
7417        let mut hvals = vec![0.0_f64; nnz];
7418        assert!(t.eval_h(
7419            Some(&x),
7420            true,
7421            obj_factor,
7422            Some(&lam),
7423            true,
7424            SparsityRequest::Values { values: &mut hvals }
7425        ));
7426        let mut dense = [[0.0_f64; 3]; 3];
7427        for k in 0..nnz {
7428            let (i, j) = (irow[k] as usize, jcol[k] as usize);
7429            dense[i][j] += hvals[k];
7430            if i != j {
7431                dense[j][i] += hvals[k];
7432            }
7433        }
7434
7435        // Each unit seed recovers a column; a mixed seed catches an HVP
7436        // that only happens to be right on the basis vectors.
7437        let seeds: [[Number; 3]; 4] = [
7438            [1.0, 0.0, 0.0],
7439            [0.0, 1.0, 0.0],
7440            [0.0, 0.0, 1.0],
7441            [0.4, -1.3, 2.0],
7442        ];
7443        let mut out = vec![0.0; 3];
7444        for s in &seeds {
7445            t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
7446                .expect("hvp");
7447            for i in 0..3 {
7448                let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
7449                assert!(
7450                    (out[i] - want).abs() < 1e-9,
7451                    "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
7452                    out[i]
7453                );
7454            }
7455        }
7456    }
7457
7458    /// `lam = None` is the objective block alone, and `out` is
7459    /// overwritten (not accumulated) so a reused buffer is safe.
7460    #[test]
7461    fn hessian_vector_product_defaults_and_validation() {
7462        // f = x0^2 + 3 x0 x1  ->  ∇²f = [[2, 3], [3, 0]]
7463        let obj = bin(
7464            BinOp::Add,
7465            bin(BinOp::Pow, v(0), c(2.0)),
7466            bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
7467        );
7468        let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
7469        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7470        t.get_nlp_info().unwrap();
7471
7472        let mut out = vec![7.0, -7.0]; // dirty buffer
7473        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
7474            .expect("hvp");
7475        assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
7476        assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
7477
7478        // obj_factor scales linearly.
7479        t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
7480            .expect("hvp");
7481        assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
7482
7483        // Length mismatches are errors, not panics or silent truncation.
7484        let mut short = vec![0.0; 1];
7485        assert!(
7486            t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
7487                .is_err()
7488        );
7489        assert!(
7490            t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
7491                .is_err()
7492        );
7493        assert!(
7494            t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
7495                .is_err()
7496        );
7497    }
7498
7499    /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
7500    /// Hessian — the sparse shape an IPM actually meets. The block HVP has
7501    /// to reproduce it column for column, including the structural zeros:
7502    /// a bug that leaked coupling between non-adjacent variables would
7503    /// show up here and nowhere in a small dense test.
7504    #[test]
7505    fn hessian_vector_products_on_a_sparse_hessian() {
7506        const N: usize = 8;
7507        let mut terms = Vec::new();
7508        for i in 0..N - 1 {
7509            terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
7510        }
7511        for i in 0..N {
7512            terms.push(un(UnaryOp::Exp, v(i)));
7513        }
7514        let prob =
7515            NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
7516        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7517        let info = t.get_nlp_info().unwrap();
7518
7519        // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
7520        assert_eq!(
7521            info.nnz_h_lag as usize,
7522            2 * N - 1,
7523            "chain objective should give a tridiagonal Hessian, not a dense one"
7524        );
7525
7526        let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
7527
7528        // Densify the sparse triangle for the reference.
7529        let nnz = info.nnz_h_lag as usize;
7530        let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
7531        assert!(t.eval_h(
7532            None,
7533            false,
7534            1.0,
7535            None,
7536            false,
7537            SparsityRequest::Structure {
7538                irow: &mut irow,
7539                jcol: &mut jcol
7540            }
7541        ));
7542        let mut hvals = vec![0.0; nnz];
7543        assert!(t.eval_h(
7544            Some(&x),
7545            true,
7546            1.0,
7547            None,
7548            true,
7549            SparsityRequest::Values { values: &mut hvals }
7550        ));
7551        let mut dense = vec![vec![0.0; N]; N];
7552        for k in 0..nnz {
7553            let (i, j) = (irow[k] as usize, jcol[k] as usize);
7554            dense[i][j] += hvals[k];
7555            if i != j {
7556                dense[j][i] += hvals[k];
7557            }
7558        }
7559
7560        // One block of N unit seeds recovers the whole matrix — the
7561        // "densify via HVPs" path, in one call.
7562        let mut seeds = vec![0.0; N * N];
7563        for cc in 0..N {
7564            seeds[cc * N + cc] = 1.0;
7565        }
7566        let mut out = vec![0.0; N * N];
7567        t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
7568            .expect("block hvp");
7569        for cc in 0..N {
7570            for i in 0..N {
7571                assert!(
7572                    (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
7573                    "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
7574                    out[cc * N + i],
7575                    dense[i][cc]
7576                );
7577            }
7578        }
7579    }
7580
7581    /// The block form must agree with `k` separate single-vector calls
7582    /// (it shares one forward sweep across directions, so a bug there
7583    /// would show as a per-direction discrepancy), and must skip an
7584    /// all-zero direction without disturbing its neighbours.
7585    #[test]
7586    fn hessian_vector_products_match_repeated_single_calls() {
7587        let obj = Expr::Sum(vec![
7588            un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
7589            bin(BinOp::Pow, v(2), c(4.0)),
7590            bin(BinOp::Mul, v(0), v(2)),
7591        ]);
7592        let cons = vec![bin(BinOp::Mul, v(1), v(2))];
7593        let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
7594        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7595        t.get_nlp_info().unwrap();
7596
7597        let x = [0.4, -0.6, 1.3];
7598        let lam = [0.75];
7599        let cols: [[Number; 3]; 4] = [
7600            [1.0, 2.0, -3.0],
7601            [0.0, 0.0, 0.0], // the skipped direction
7602            [0.5, 0.0, 0.0],
7603            [-1.0, 1.0, 1.0],
7604        ];
7605
7606        let mut block = vec![0.0; 3 * cols.len()];
7607        let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
7608        t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
7609            .expect("block hvp");
7610
7611        for (c, col) in cols.iter().enumerate() {
7612            let mut single = vec![0.0; 3];
7613            t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
7614                .expect("single hvp");
7615            for i in 0..3 {
7616                assert!(
7617                    (block[c * 3 + i] - single[i]).abs() < 1e-12,
7618                    "direction {c} row {i}: block={:.12e} single={:.12e}",
7619                    block[c * 3 + i],
7620                    single[i]
7621                );
7622            }
7623        }
7624        // The zero direction really is zero, not stale scratch.
7625        assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
7626    }
7627
7628    /// `k = 0` is a legal empty block, and the length checks scale with
7629    /// `k` rather than assuming a single direction.
7630    #[test]
7631    fn hessian_vector_products_validate_block_shape() {
7632        let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
7633            .expect("build");
7634        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7635        t.get_nlp_info().unwrap();
7636
7637        let mut empty: Vec<Number> = Vec::new();
7638        assert!(
7639            t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
7640                .is_ok()
7641        );
7642
7643        // v sized for one direction while k says two.
7644        let mut out = vec![0.0; 4];
7645        assert!(
7646            t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
7647                .is_err()
7648        );
7649        // out sized for one direction while k says two.
7650        let mut short = vec![0.0; 2];
7651        assert!(
7652            t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
7653                .is_err()
7654        );
7655    }
7656
7657    /// A `maximize` model's objective is negated by the evaluator, and
7658    /// the HVP has to agree with `eval_h` about that — otherwise a
7659    /// Hessian-free step would climb where the sparse path descends.
7660    #[test]
7661    fn hessian_vector_product_respects_maximize_sign() {
7662        let obj = bin(BinOp::Pow, v(0), c(2.0));
7663        let mut p = parts(1, obj, Vec::new());
7664        p.minimize = false;
7665        let prob = NlProblem::from_expressions(p).expect("build");
7666        let mut t = NlTnlp::try_new(prob).expect("tnlp");
7667        t.get_nlp_info().unwrap();
7668
7669        // max x0^2 is minimized as -x0^2, so ∇² = -2.
7670        let mut out = vec![0.0; 1];
7671        t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
7672            .expect("hvp");
7673        assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
7674    }
7675}