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. Initial dual (`d`) is
20//!   read and discarded.
21//! * Multiple objectives (we use only the first; per AMPL convention).
22//!
23//! Not supported (will return an error explaining what's missing):
24//! * Network / piecewise-linear constructs.
25//! * Complementarity rows.
26//! * Binary-format `.nl` files (`b…` header).
27//!
28//! References:
29//! * <https://ampl.com/REFS/hooking2.pdf> — "Hooking Your Solver to
30//!   AMPL" (David M. Gay), the canonical `.nl` spec.
31//! * `ref/Ipopt/test/mytoy.nl` — annotated example used for the unit
32//!   tests in this module.
33
34use crate::nl_tape::Tape;
35use pounce_common::types::{Index, Number};
36use pounce_nlp::tnlp::{
37    BoundsInfo, IndexStyle, IpoptCq, IpoptData, Linearity, MetaData, NlpInfo, Solution,
38    SparsityRequest, StartingPoint, IDX_NAMES, TNLP,
39};
40use std::cell::RefCell;
41use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::path::Path;
43use std::rc::Rc;
44
45#[derive(Debug, Clone)]
46pub enum Expr {
47    /// Numeric constant.
48    Const(Number),
49    /// Variable reference (0-based index into `x`).
50    Var(usize),
51    /// Binary op: `args = [lhs, rhs]`.
52    Binary(BinOp, Box<Expr>, Box<Expr>),
53    /// Unary op.
54    Unary(UnaryOp, Box<Expr>),
55    /// n-ary sum (opcode `o54` — variadic; we may emit it from `o0`
56    /// folding optimization, but the parser treats `o0` as binary).
57    Sum(Vec<Expr>),
58    /// Reference to a common subexpression (`.nl` `V` segment). The
59    /// payload is a shared body; many references to the same CSE share
60    /// one `Rc`, so the parsed problem is a DAG. Walking through `Cse`
61    /// is mathematically equivalent to inlining the body at each
62    /// occurrence (every reference is an independent occurrence in the
63    /// chain rule), so eval/grad/collect_vars just recurse into the
64    /// inner `Expr`.
65    Cse(Rc<Expr>),
66    /// AMPL imported (external) function call. `id` matches an entry in
67    /// `NlProblem.imported_funcs`; resolution to a live shared library
68    /// happens when the tape is built (see `nl_external::ExternalResolver`).
69    Funcall { id: usize, args: Vec<FuncallArg> },
70    /// Relational comparison (`o22`/`o23`/`o24`/`o28`/`o29`/`o30`).
71    /// Evaluates to `1.0` when the comparison holds, else `0.0`. The
72    /// result is piecewise-constant, so it has zero derivative
73    /// everywhere (the kink at equality is ignored — standard
74    /// subgradient-free treatment, matching ASL).
75    Compare(CmpOp, Box<Expr>, Box<Expr>),
76    /// Logical AND (`o21`). `1.0` iff both operands are nonzero.
77    /// Zero derivative (piecewise constant).
78    And(Box<Expr>, Box<Expr>),
79    /// Logical OR (`o20`). `1.0` iff either operand is nonzero.
80    /// Zero derivative (piecewise constant).
81    Or(Box<Expr>, Box<Expr>),
82    /// Logical NOT (`o34`). `1.0` iff the operand is zero.
83    /// Zero derivative (piecewise constant).
84    Not(Box<Expr>),
85    /// `if-then-else` (`o35` OPIFnl). Evaluates `cond`; when it is
86    /// nonzero the value and all derivatives flow through `then_`,
87    /// otherwise through `else_`. The branch switch is a non-smooth
88    /// event the derivative ignores (it differentiates only the
89    /// active branch), exactly as ASL/IPOPT does for `if`.
90    Cond {
91        cond: Box<Expr>,
92        then_: Box<Expr>,
93        else_: Box<Expr>,
94    },
95    /// n-ary minimum (`o11` MINLIST). Value is the smallest operand.
96    /// Piecewise linear: the derivative flows through whichever operand
97    /// is currently smallest (a subgradient; ties resolve to the first
98    /// such operand), and the second derivative is identically zero —
99    /// the standard AD treatment for min/max, matching ASL/IPOPT.
100    MinList(Vec<Expr>),
101    /// n-ary maximum (`o12` MAXLIST). Value is the largest operand;
102    /// derivative routing mirrors [`Expr::MinList`].
103    MaxList(Vec<Expr>),
104}
105
106/// Relational operator carried by [`Expr::Compare`]. The variants map
107/// 1:1 onto AMPL opcodes `o22 LT`, `o23 LE`, `o24 EQ`, `o28 GE`,
108/// `o29 GT`, `o30 NE`.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum CmpOp {
111    Lt,
112    Le,
113    Eq,
114    Ge,
115    Gt,
116    Ne,
117}
118
119/// One positional argument to an AMPL imported function call. AMPL splits
120/// arguments into reals (carried by `ra[]`) and strings (carried by `sa[]`);
121/// `FuncallArg` mirrors that split. Real args are arbitrary expressions.
122#[derive(Debug, Clone)]
123pub enum FuncallArg {
124    Real(Expr),
125    Str(String),
126}
127
128/// An AMPL imported (external) function declaration from a top-level
129/// `F<id> <type> <nargs> <name>` segment.
130#[derive(Debug, Clone)]
131pub struct ImportedFunc {
132    pub id: usize,
133    /// 0 = real-valued, 1 = string-args (per AMPL's funcadd ABI).
134    pub kind: usize,
135    /// Declared arg count. >=0 exact arity; <=-1 means at least `-(nargs+1)`.
136    pub nargs: i64,
137    pub name: String,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum BinOp {
142    Add,
143    Sub,
144    Mul,
145    Div,
146    Pow,
147    /// Two-argument arctangent `atan2(a, b)` with operands `(y, x)`.
148    Atan2,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum UnaryOp {
153    Neg,
154    Sqrt,
155    Log,
156    Exp,
157    Abs,
158    Sin,
159    Cos,
160    Log10,
161    Tan,
162    Atan,
163    Acos,
164    Sinh,
165    Cosh,
166    Tanh,
167    Asin,
168    Acosh,
169    Asinh,
170    Atanh,
171}
172
173/// Parsed `.nl` problem in the form needed by `NlTnlp`.
174#[derive(Debug, Clone)]
175pub struct NlProblem {
176    pub n: usize,
177    pub m: usize,
178    pub num_obj: usize,
179    pub minimize: bool,
180    pub obj_nonlinear: Expr,
181    pub obj_linear: Vec<(usize, Number)>,
182    pub obj_constant: Number,
183    /// Per-constraint nonlinear part (length m).
184    pub con_nonlinear: Vec<Expr>,
185    /// Per-constraint linear part (length m), each a list of (var, coef).
186    pub con_linear: Vec<Vec<(usize, Number)>>,
187    pub x_l: Vec<Number>,
188    pub x_u: Vec<Number>,
189    pub g_l: Vec<Number>,
190    pub g_u: Vec<Number>,
191    pub x0: Vec<Number>,
192    pub lambda0: Vec<Number>,
193    /// AMPL suffix dictionaries. Variable / constraint / objective
194    /// suffixes are stored as dense vectors (length n / m / num_obj)
195    /// with the sparse `.nl` `S`-segment entries scattered in, default
196    /// zero. The integer / real split matches the `S`-segment header's
197    /// kind bit (`0x4` ⇒ real, else integer). See
198    /// <https://ampl.com/REFS/hooking2.pdf> §6 and the upstream `.nl`
199    /// reader in `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`.
200    pub suffixes: NlSuffixes,
201    /// AMPL imported (external) functions declared via top-level `F` segments.
202    /// Empty unless the `.nl` file calls compiled-C user functions (typically
203    /// emitted by IDAES property packages — see issue #49).
204    pub imported_funcs: Vec<ImportedFunc>,
205    /// Variable names from the sibling `.col` file, index-aligned to `x`
206    /// (one name per line, column order). Empty when no `.col` file was
207    /// found — AMPL only emits it under `option auxfiles rc;`.
208    ///
209    /// Carrying names lets diagnostics report `flow_balance` / `T_reactor`
210    /// instead of `c[3]` / `x[132]`. Lee et al. (2024) identify the gap
211    /// between detecting an issue and tracing it to a *named* equation as a
212    /// central roadblock for equation-oriented model debugging; threading
213    /// names through to the solver/debugger is the prerequisite for closing
214    /// it. See <https://doi.org/10.69997/sct.147875>.
215    pub var_names: Vec<String>,
216    /// Constraint names from the sibling `.row` file, index-aligned to `g`
217    /// (one name per line, row order). Empty when no `.row` file was found.
218    /// See [`NlProblem::var_names`] for why names are captured.
219    pub con_names: Vec<String>,
220}
221
222/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
223/// into dense vectors at problem load time so callers can index by
224/// variable / constraint number directly. Empty maps when the `.nl`
225/// file declared no suffixes.
226#[derive(Debug, Clone, Default)]
227pub struct NlSuffixes {
228    /// Variable-level integer suffixes (kind = 0). Each vector has
229    /// length `n_full` (problem variables).
230    pub var_int: BTreeMap<String, Vec<Index>>,
231    /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
232    pub con_int: BTreeMap<String, Vec<Index>>,
233    /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
234    pub obj_int: BTreeMap<String, Vec<Index>>,
235    /// Problem-level integer suffixes (kind = 3). Single value per name.
236    pub problem_int: BTreeMap<String, Index>,
237    /// Variable-level real suffixes (kind = 4). Length `n_full`.
238    pub var_real: BTreeMap<String, Vec<Number>>,
239    /// Constraint-level real suffixes (kind = 5). Length `m_full`.
240    pub con_real: BTreeMap<String, Vec<Number>>,
241    /// Objective-level real suffixes (kind = 6). Length `num_obj`.
242    pub obj_real: BTreeMap<String, Vec<Number>>,
243    /// Problem-level real suffixes (kind = 7). Single value per name.
244    pub problem_real: BTreeMap<String, Number>,
245}
246
247/// Parse an `.nl` file from disk.
248///
249/// After parsing the `.nl` body, this also looks for AMPL's optional
250/// sibling name files — `stub.col` (variable names) and `stub.row`
251/// (constraint names), emitted only when the modeler sets
252/// `option auxfiles rc;`. When present and well-formed they populate
253/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
254/// malformed the names stay empty and every downstream consumer falls
255/// back to indices. Names are a diagnostic nicety, never load-blocking
256/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
257pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
258    let txt = std::fs::read_to_string(path)
259        .map_err(|e| format!("could not read {}: {}", path.display(), e))?;
260    let mut prob = parse_nl_text(&txt)?;
261    prob.var_names = read_name_file(&path.with_extension("col"), prob.n);
262    prob.con_names = read_name_file(&path.with_extension("row"), prob.m);
263    Ok(prob)
264}
265
266/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
267/// order. Returns the first `expected` names, or an empty vector when the
268/// file is missing, unreadable, or has fewer than `expected` lines.
269///
270/// Returning empty (rather than erroring) on any mismatch is deliberate:
271/// names are an optional diagnostic aid, so a missing or truncated file
272/// must never block a solve. The `.take(expected)` also drops AMPL's
273/// convention of appending the objective name after the constraint names
274/// in `.row`, keeping the result aligned 1:1 with `g`.
275fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
276    let Ok(txt) = std::fs::read_to_string(path) else {
277        return Vec::new();
278    };
279    let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
280    if names.len() == expected {
281        names
282    } else {
283        Vec::new()
284    }
285}
286
287/// Parse `.nl` text content. Public so tests can use string literals.
288pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
289    let mut p = Parser::new(txt);
290    p.parse_header()?;
291    let n = p.n;
292    let m = p.m;
293    let num_obj = p.num_obj;
294
295    let mut con_nonlinear: Vec<Expr> = (0..m).map(|_| Expr::Const(0.0)).collect();
296    let mut obj_nonlinear = Expr::Const(0.0);
297    let mut minimize = true;
298    let mut obj_linear: Vec<(usize, Number)> = Vec::new();
299    let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
300    let mut x_l = vec![-1e19; n];
301    let mut x_u = vec![1e19; n];
302    let mut g_l = vec![-1e19; m];
303    let mut g_u = vec![1e19; m];
304    let mut x0 = vec![0.0; n];
305    let mut lambda0 = vec![0.0; m];
306    let mut suffixes = NlSuffixes::default();
307    let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
308
309    while let Some(line) = p.peek_segment_line() {
310        let tag = line
311            .trim_start()
312            .chars()
313            .next()
314            .ok_or("unexpected blank segment header")?;
315        match tag {
316            'C' => {
317                let (_hdr, rest) = p.eat_segment_header()?;
318                let _ = rest;
319                let idx = parse_segment_index(&_hdr, 'C')?;
320                if idx >= m {
321                    return Err(format!("C{idx} out of range; m={m}"));
322                }
323                con_nonlinear[idx] = p.parse_expr()?;
324            }
325            'O' => {
326                let (hdr, _rest) = p.eat_segment_header()?;
327                let parts: Vec<&str> = hdr.split_whitespace().collect();
328                if parts.len() < 2 {
329                    return Err(format!("malformed O-segment header: {hdr}"));
330                }
331                let idx = parse_segment_index(parts[0], 'O')?;
332                let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
333                if idx == 0 {
334                    minimize = kind == 0;
335                    obj_nonlinear = p.parse_expr()?;
336                } else {
337                    // Extra objectives are read but ignored.
338                    let _ = p.parse_expr()?;
339                }
340            }
341            'r' => {
342                p.eat_segment_header()?;
343                for i in 0..m {
344                    let line = p.next_data_line()?;
345                    let (lo, hi) = parse_bound_line(&line)?;
346                    g_l[i] = lo;
347                    g_u[i] = hi;
348                }
349            }
350            'b' => {
351                p.eat_segment_header()?;
352                for i in 0..n {
353                    let line = p.next_data_line()?;
354                    let (lo, hi) = parse_bound_line(&line)?;
355                    x_l[i] = lo;
356                    x_u[i] = hi;
357                }
358            }
359            'k' => {
360                // Column counts in the Jacobian; we don't need them
361                // for evaluation since J segments give explicit lists.
362                p.eat_segment_header()?;
363                let count = if n == 0 { 0 } else { n - 1 };
364                for _ in 0..count {
365                    p.next_data_line()?;
366                }
367            }
368            'J' => {
369                let (hdr, _) = p.eat_segment_header()?;
370                let parts: Vec<&str> = hdr.split_whitespace().collect();
371                if parts.len() < 2 {
372                    return Err(format!("malformed J-segment header: {hdr}"));
373                }
374                let row = parse_segment_index(parts[0], 'J')?;
375                let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
376                if row >= m {
377                    return Err(format!("J{row} out of range"));
378                }
379                for _ in 0..nz {
380                    let line = p.next_data_line()?;
381                    let (var, coef) = parse_var_coef(&line)?;
382                    con_linear[row].push((var, coef));
383                }
384            }
385            'G' => {
386                let (hdr, _) = p.eat_segment_header()?;
387                let parts: Vec<&str> = hdr.split_whitespace().collect();
388                if parts.len() < 2 {
389                    return Err(format!("malformed G-segment header: {hdr}"));
390                }
391                let idx = parse_segment_index(parts[0], 'G')?;
392                let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
393                let mut acc = Vec::with_capacity(nz);
394                for _ in 0..nz {
395                    let line = p.next_data_line()?;
396                    let (var, coef) = parse_var_coef(&line)?;
397                    acc.push((var, coef));
398                }
399                if idx == 0 {
400                    obj_linear = acc;
401                }
402            }
403            'x' => {
404                let (hdr, _) = p.eat_segment_header()?;
405                let parts: Vec<&str> = hdr.split_whitespace().collect();
406                let nx: usize = parts
407                    .first()
408                    .and_then(|s| s.trim_start_matches('x').parse().ok())
409                    .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
410                for _ in 0..nx {
411                    let line = p.next_data_line()?;
412                    let (idx, val) = parse_var_coef(&line)?;
413                    if idx < n {
414                        x0[idx] = val;
415                    }
416                }
417            }
418            'd' => {
419                let (hdr, _) = p.eat_segment_header()?;
420                let parts: Vec<&str> = hdr.split_whitespace().collect();
421                let nd: usize = parts
422                    .first()
423                    .and_then(|s| s.trim_start_matches('d').parse().ok())
424                    .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
425                for _ in 0..nd {
426                    let line = p.next_data_line()?;
427                    let (idx, val) = parse_var_coef(&line)?;
428                    if idx < m {
429                        lambda0[idx] = val;
430                    }
431                }
432            }
433            'V' => p.parse_v_segment()?,
434            'S' => {
435                parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
436            }
437            'F' => {
438                // AMPL imported (external) function declaration:
439                // `F<k> <type> <nargs> <name>`.
440                let (hdr, _rest) = p.eat_segment_header()?;
441                let parts: Vec<&str> = hdr.split_whitespace().collect();
442                if parts.is_empty() {
443                    return Err(format!("malformed F-segment header: '{hdr}'"));
444                }
445                let id = parse_segment_index(parts[0], 'F')?;
446                let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
447                let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
448                let name = parts.get(3).copied().unwrap_or("").to_string();
449                imported_funcs.push(ImportedFunc {
450                    id,
451                    kind,
452                    nargs,
453                    name,
454                });
455            }
456            other => return Err(format!("unknown .nl segment tag '{other}'")),
457        }
458    }
459
460    Ok(NlProblem {
461        n,
462        m,
463        num_obj,
464        minimize,
465        obj_nonlinear,
466        obj_linear,
467        obj_constant: 0.0,
468        con_nonlinear,
469        con_linear,
470        x_l,
471        x_u,
472        g_l,
473        g_u,
474        x0,
475        lambda0,
476        suffixes,
477        imported_funcs,
478        // `.nl` text carries no names; `read_nl_file` fills these from the
479        // sibling `.col`/`.row` files when present.
480        var_names: Vec::new(),
481        con_names: Vec::new(),
482    })
483}
484
485/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
486/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
487///
488/// ```text
489/// S<kind> <nentries> <suffix_name>
490/// <idx> <value>      ... nentries lines
491/// ```
492///
493/// `<kind>` is a 3-bit encoding:
494/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
495///   2 = objectives, 3 = problem-level.
496/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
497///
498/// Sparse entries scatter into a freshly-allocated dense vector (zero
499/// default), sized for the target dimension. Problem-level suffixes
500/// (kind = 3 / 7) carry a single value.
501fn parse_suffix_segment(
502    p: &mut Parser,
503    n: usize,
504    m: usize,
505    num_obj: usize,
506    out: &mut NlSuffixes,
507) -> Result<(), String> {
508    let (hdr, _) = p.eat_segment_header()?;
509    let parts: Vec<&str> = hdr.split_whitespace().collect();
510    if parts.len() < 3 {
511        return Err(format!(
512            "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
513        ));
514    }
515    let kind_str = parts[0].trim_start_matches('S');
516    let kind: u32 = kind_str
517        .parse()
518        .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
519    let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
520    let name = parts[2].to_string();
521
522    let is_real = (kind & 0x4) != 0;
523    let target = kind & 0x3;
524    let target_dim = match target {
525        0 => n,
526        1 => m,
527        2 => num_obj,
528        3 => 0, // problem-level — entries are single-valued (idx=0)
529        _ => unreachable!("kind & 0x3 is in 0..=3"),
530    };
531
532    // Pre-allocate dense buffers (default zero). Problem-level kinds
533    // (3 / 7) hold a single scalar — we still read the (idx, value)
534    // pairs but only the value field is meaningful.
535    let mut int_buf: Vec<Index> = if !is_real && target != 3 {
536        vec![0; target_dim]
537    } else {
538        Vec::new()
539    };
540    let mut real_buf: Vec<Number> = if is_real && target != 3 {
541        vec![0.0; target_dim]
542    } else {
543        Vec::new()
544    };
545    let mut problem_int: Index = 0;
546    let mut problem_real: Number = 0.0;
547
548    for _ in 0..nentries {
549        let line = p.next_data_line()?;
550        let parts: Vec<&str> = line.split_whitespace().collect();
551        if parts.len() < 2 {
552            return Err(format!(
553                "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
554            ));
555        }
556        let idx: usize = parts[0]
557            .parse()
558            .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
559        if target != 3 && idx >= target_dim {
560            return Err(format!(
561                "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
562            ));
563        }
564        if is_real {
565            let v: Number = parts[1]
566                .parse()
567                .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
568            if target == 3 {
569                problem_real = v;
570            } else {
571                real_buf[idx] = v;
572            }
573        } else {
574            let v: Index = parts[1]
575                .parse()
576                .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
577            if target == 3 {
578                problem_int = v;
579            } else {
580                int_buf[idx] = v;
581            }
582        }
583    }
584
585    match (target, is_real) {
586        (0, false) => {
587            out.var_int.insert(name, int_buf);
588        }
589        (1, false) => {
590            out.con_int.insert(name, int_buf);
591        }
592        (2, false) => {
593            out.obj_int.insert(name, int_buf);
594        }
595        (3, false) => {
596            out.problem_int.insert(name, problem_int);
597        }
598        (0, true) => {
599            out.var_real.insert(name, real_buf);
600        }
601        (1, true) => {
602            out.con_real.insert(name, real_buf);
603        }
604        (2, true) => {
605            out.obj_real.insert(name, real_buf);
606        }
607        (3, true) => {
608            out.problem_real.insert(name, problem_real);
609        }
610        _ => unreachable!(),
611    }
612    Ok(())
613}
614
615fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
616    let trimmed = s.trim_start_matches(tag);
617    trimmed
618        .parse()
619        .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
620}
621
622fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
623    let parts: Vec<&str> = line.split_whitespace().collect();
624    if parts.is_empty() {
625        return Err("empty bound line".into());
626    }
627    let kind: i32 = parts[0].parse().map_err(|e| format!("bound kind: {e}"))?;
628    let lo;
629    let hi;
630    match kind {
631        0 => {
632            // 0  lo  hi
633            if parts.len() < 3 {
634                return Err(format!("bound kind 0 needs 2 values: '{line}'"));
635            }
636            lo = parts[1].parse().map_err(|e| format!("lo: {e}"))?;
637            hi = parts[2].parse().map_err(|e| format!("hi: {e}"))?;
638        }
639        1 => {
640            // 1  hi
641            if parts.len() < 2 {
642                return Err(format!("bound kind 1 needs 1 value: '{line}'"));
643            }
644            lo = -1e19;
645            hi = parts[1].parse().map_err(|e| format!("hi: {e}"))?;
646        }
647        2 => {
648            // 2  lo
649            if parts.len() < 2 {
650                return Err(format!("bound kind 2 needs 1 value: '{line}'"));
651            }
652            lo = parts[1].parse().map_err(|e| format!("lo: {e}"))?;
653            hi = 1e19;
654        }
655        3 => {
656            // 3  (free)
657            lo = -1e19;
658            hi = 1e19;
659        }
660        4 => {
661            // 4  eq
662            if parts.len() < 2 {
663                return Err(format!("bound kind 4 needs 1 value: '{line}'"));
664            }
665            let v: Number = parts[1].parse().map_err(|e| format!("eq: {e}"))?;
666            lo = v;
667            hi = v;
668        }
669        5 => return Err("complementarity (kind 5) bounds are not supported".into()),
670        other => return Err(format!("unknown bound kind {other}")),
671    }
672    Ok((lo, hi))
673}
674
675fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
676    let parts: Vec<&str> = line.split_whitespace().collect();
677    if parts.len() < 2 {
678        return Err(format!("malformed var/coef line: '{line}'"));
679    }
680    let v: usize = parts[0].parse().map_err(|e| format!("var idx: {e}"))?;
681    let c: Number = parts[1].parse().map_err(|e| format!("coef: {e}"))?;
682    Ok((v, c))
683}
684
685struct Parser<'a> {
686    lines: Vec<&'a str>,
687    pos: usize,
688    n: usize,
689    m: usize,
690    num_obj: usize,
691    /// Number of AMPL imported (external) functions declared in the header.
692    n_funcs: usize,
693    /// Common subexpressions (`V` segments). Index in this vec is the
694    /// CSE-local index, i.e. the global `.nl` index minus `n`.
695    cses: Vec<Rc<Expr>>,
696}
697
698impl<'a> Parser<'a> {
699    fn new(txt: &'a str) -> Self {
700        let lines: Vec<&str> = txt.lines().collect();
701        Self {
702            lines,
703            pos: 0,
704            n: 0,
705            m: 0,
706            num_obj: 0,
707            n_funcs: 0,
708            cses: Vec::new(),
709        }
710    }
711
712    fn next_line(&mut self) -> Option<&'a str> {
713        while self.pos < self.lines.len() {
714            let l = self.lines[self.pos];
715            self.pos += 1;
716            // Strip comment after '#' for header / data lines (but
717            // leave the segment-tag tokens untouched — they are the
718            // first token on the line).
719            let trimmed = strip_comment(l).trim();
720            if !trimmed.is_empty() {
721                return Some(l);
722            }
723        }
724        None
725    }
726
727    fn next_data_line(&mut self) -> Result<String, String> {
728        let raw = self
729            .next_line()
730            .ok_or_else(|| "unexpected end of file in data line".to_string())?;
731        Ok(strip_comment(raw).trim().to_string())
732    }
733
734    fn parse_header(&mut self) -> Result<(), String> {
735        let line0 = self.next_line().ok_or("empty .nl file")?;
736        let trimmed = strip_comment(line0).trim();
737        let first = trimmed.chars().next().ok_or("empty header line")?;
738        if first != 'g' {
739            return Err(format!(
740                "only ASCII (g-) .nl files supported; got header '{trimmed}'"
741            ));
742        }
743
744        // Header line 2: n_vars n_cons n_objs ranges eqns
745        let l2 = self.next_data_line()?;
746        let nums: Vec<&str> = l2.split_whitespace().collect();
747        if nums.len() < 3 {
748            return Err(format!("malformed line 2: '{l2}'"));
749        }
750        self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
751        self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
752        self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
753
754        // Lines 3..5 are metadata we skip.
755        for _ in 0..3 {
756            self.next_data_line()?;
757        }
758        // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
759        let l5 = self.next_data_line()?;
760        let nums5: Vec<&str> = l5.split_whitespace().collect();
761        self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
762        // Lines 6..10 are metadata we don't need — skip 4 more lines.
763        for _ in 0..4 {
764            self.next_data_line()?;
765        }
766        Ok(())
767    }
768
769    fn peek_segment_line(&mut self) -> Option<&'a str> {
770        let saved = self.pos;
771        let l = self.next_line()?;
772        self.pos = saved;
773        Some(l)
774    }
775
776    /// Eat the next non-blank line as a segment header. Returns the
777    /// whole header (after stripping comments) and the comment text.
778    fn eat_segment_header(&mut self) -> Result<(String, String), String> {
779        let raw = self
780            .next_line()
781            .ok_or_else(|| "expected segment header".to_string())?;
782        let (hdr, comment) = split_comment(raw);
783        Ok((hdr.trim().to_string(), comment.trim().to_string()))
784    }
785
786    fn parse_expr(&mut self) -> Result<Expr, String> {
787        let raw = self
788            .next_line()
789            .ok_or_else(|| "expected expression token".to_string())?;
790        let tok = strip_comment(raw).trim().to_string();
791        if tok.is_empty() {
792            return Err("empty expression token".into());
793        }
794        let first = tok.chars().next().ok_or("empty expression token")?;
795        match first {
796            'n' => {
797                let v: Number = tok[1..]
798                    .trim()
799                    .parse()
800                    .map_err(|e| format!("n value: {e}"))?;
801                Ok(Expr::Const(v))
802            }
803            'v' => {
804                let i: usize = tok[1..]
805                    .trim()
806                    .parse()
807                    .map_err(|e| format!("v index: {e}"))?;
808                Ok(self.var_or_cse(i)?)
809            }
810            'o' => {
811                let code: i32 = tok[1..]
812                    .trim()
813                    .parse()
814                    .map_err(|e| format!("opcode: {e}"))?;
815                self.parse_opcode(code)
816            }
817            'f' => {
818                // AMPL imported (external) function call: `f<id> <nargs>`
819                // followed by nargs child expressions (or string literals).
820                let rest = &tok[1..];
821                let mut parts = rest.split_whitespace();
822                let id_str = parts
823                    .next()
824                    .ok_or_else(|| format!("missing function id in '{tok}'"))?;
825                let nargs_str = parts
826                    .next()
827                    .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
828                let id: usize = id_str
829                    .parse()
830                    .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
831                let nargs: usize = nargs_str
832                    .parse()
833                    .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
834                let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
835                for _ in 0..nargs {
836                    args.push(self.parse_funcall_arg()?);
837                }
838                Ok(Expr::Funcall { id, args })
839            }
840            't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
841            other => Err(format!(
842                "unexpected expression token start '{other}': '{tok}'"
843            )),
844        }
845    }
846
847    /// Parse one argument to an AMPL imported function. An argument
848    /// is either a normal expression (real-valued) or a string literal
849    /// in the form `h<len>:<chars>`. AMPL emits string args only when the
850    /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
851    /// or a parameters-directory path for IDAES Helmholtz functions).
852    fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
853        // Peek the next non-blank line so we can route `h...` differently.
854        let saved = self.pos;
855        let raw = self
856            .next_line()
857            .ok_or_else(|| "expected funcall argument".to_string())?;
858        let tok = strip_comment(raw).trim().to_string();
859        let first = tok.chars().next().ok_or("empty funcall arg token")?;
860        if first == 'h' {
861            // `h<len>:<chars>` — strip the `<len>:` prefix.
862            let rest = &tok[1..];
863            let s = match rest.find(':') {
864                Some(i) => rest[i + 1..].to_string(),
865                None => String::new(),
866            };
867            Ok(FuncallArg::Str(s))
868        } else {
869            // Rewind: parse_expr re-consumes the line we just peeked.
870            self.pos = saved;
871            Ok(FuncallArg::Real(self.parse_expr()?))
872        }
873    }
874
875    fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
876        match code {
877            0 => {
878                let a = self.parse_expr()?;
879                let b = self.parse_expr()?;
880                Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
881            }
882            1 => {
883                let a = self.parse_expr()?;
884                let b = self.parse_expr()?;
885                Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
886            }
887            2 => {
888                let a = self.parse_expr()?;
889                let b = self.parse_expr()?;
890                Ok(Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b)))
891            }
892            3 => {
893                let a = self.parse_expr()?;
894                let b = self.parse_expr()?;
895                Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
896            }
897            5 => {
898                let a = self.parse_expr()?;
899                let b = self.parse_expr()?;
900                Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
901            }
902            15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
903            16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
904            39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
905            41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
906            42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
907            43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
908            44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
909            46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
910            38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
911            49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
912            53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
913            40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
914            45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
915            37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
916            51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
917            52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
918            50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
919            47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
920            // atan2(y, x): binary, operand order `y` then `x`.
921            48 => {
922                let a = self.parse_expr()?;
923                let b = self.parse_expr()?;
924                Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
925            }
926            // Relational comparisons (binary). Operand order is
927            // `left OP right`.
928            22 => self.parse_compare(CmpOp::Lt),
929            23 => self.parse_compare(CmpOp::Le),
930            24 => self.parse_compare(CmpOp::Eq),
931            28 => self.parse_compare(CmpOp::Ge),
932            29 => self.parse_compare(CmpOp::Gt),
933            30 => self.parse_compare(CmpOp::Ne),
934            // Logical connectives.
935            20 => {
936                let a = self.parse_expr()?;
937                let b = self.parse_expr()?;
938                Ok(Expr::Or(Box::new(a), Box::new(b)))
939            }
940            21 => {
941                let a = self.parse_expr()?;
942                let b = self.parse_expr()?;
943                Ok(Expr::And(Box::new(a), Box::new(b)))
944            }
945            34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
946            // if-then-else: condition, then-value, else-value.
947            35 => {
948                let cond = self.parse_expr()?;
949                let then_ = self.parse_expr()?;
950                let else_ = self.parse_expr()?;
951                Ok(Expr::Cond {
952                    cond: Box::new(cond),
953                    then_: Box::new(then_),
954                    else_: Box::new(else_),
955                })
956            }
957            54 => {
958                // Variadic sum: next data line gives the count.
959                let count_line = self.next_data_line()?;
960                let count: usize = count_line
961                    .split_whitespace()
962                    .next()
963                    .ok_or_else(|| "missing variadic count".to_string())?
964                    .parse()
965                    .map_err(|e| format!("variadic count: {e}"))?;
966                let mut args = Vec::with_capacity(count);
967                for _ in 0..count {
968                    args.push(self.parse_expr()?);
969                }
970                Ok(Expr::Sum(args))
971            }
972            // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
973            // a count data line followed by that many operands.
974            11 | 12 => {
975                let count_line = self.next_data_line()?;
976                let count: usize = count_line
977                    .split_whitespace()
978                    .next()
979                    .ok_or_else(|| "missing min/max list count".to_string())?
980                    .parse()
981                    .map_err(|e| format!("min/max list count: {e}"))?;
982                let mut args = Vec::with_capacity(count);
983                for _ in 0..count {
984                    args.push(self.parse_expr()?);
985                }
986                if code == 11 {
987                    Ok(Expr::MinList(args))
988                } else {
989                    Ok(Expr::MaxList(args))
990                }
991            }
992            other => Err(format!("unsupported opcode o{other}")),
993        }
994    }
995
996    /// Parse the two operands of a relational opcode into an
997    /// [`Expr::Compare`]. Operand order is `left OP right`.
998    fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
999        let a = self.parse_expr()?;
1000        let b = self.parse_expr()?;
1001        Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
1002    }
1003
1004    /// Resolve a `v<i>` token into either a plain variable reference
1005    /// (`i < n`) or a shared CSE reference (`i >= n`).
1006    fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
1007        if i < self.n {
1008            Ok(Expr::Var(i))
1009        } else {
1010            let local = i - self.n;
1011            self.cses
1012                .get(local)
1013                .map(|rc| Expr::Cse(rc.clone()))
1014                .ok_or_else(|| {
1015                    format!(
1016                        "v{i} references CSE {local} but only {} have been defined",
1017                        self.cses.len()
1018                    )
1019                })
1020        }
1021    }
1022
1023    /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
1024    /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
1025    /// CSEs are numbered starting at `n` and must appear in order.
1026    fn parse_v_segment(&mut self) -> Result<(), String> {
1027        let (hdr, _) = self.eat_segment_header()?;
1028        let parts: Vec<&str> = hdr.split_whitespace().collect();
1029        if parts.len() < 2 {
1030            return Err(format!("malformed V-segment header: {hdr}"));
1031        }
1032        let cse_idx = parse_segment_index(parts[0], 'V')?;
1033        let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
1034        // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
1035        let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
1036        for _ in 0..nlin {
1037            let line = self.next_data_line()?;
1038            let (var, coef) = parse_var_coef(&line)?;
1039            linear.push((var, coef));
1040        }
1041        let nonlin = self.parse_expr()?;
1042        // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
1043        // reference earlier CSEs as well as plain variables.
1044        let mut combined = nonlin;
1045        for (var, coef) in linear {
1046            let v_expr = self.var_or_cse(var)?;
1047            let term = if coef == 1.0 {
1048                v_expr
1049            } else {
1050                Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
1051            };
1052            combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
1053        }
1054        if cse_idx < self.n {
1055            return Err(format!("V{cse_idx} below n={}", self.n));
1056        }
1057        let local = cse_idx - self.n;
1058        if local != self.cses.len() {
1059            return Err(format!(
1060                "V-segment index V{cse_idx} out of order; expected V{}",
1061                self.n + self.cses.len()
1062            ));
1063        }
1064        self.cses.push(Rc::new(combined));
1065        Ok(())
1066    }
1067}
1068
1069fn strip_comment(s: &str) -> &str {
1070    match s.find('#') {
1071        Some(i) => &s[..i],
1072        None => s,
1073    }
1074}
1075
1076fn split_comment(s: &str) -> (&str, &str) {
1077    match s.find('#') {
1078        Some(i) => (&s[..i], &s[i + 1..]),
1079        None => (s, ""),
1080    }
1081}
1082
1083// --------------------------------------------------------------------
1084// Expression evaluation and gradient (tree walkers, kept for tests).
1085// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
1086// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
1087// --------------------------------------------------------------------
1088
1089/// Forward-mode value evaluation.
1090pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
1091    match e {
1092        Expr::Const(c) => *c,
1093        Expr::Var(i) => x[*i],
1094        Expr::Binary(op, a, b) => {
1095            let va = eval_expr(a, x);
1096            let vb = eval_expr(b, x);
1097            match op {
1098                BinOp::Add => va + vb,
1099                BinOp::Sub => va - vb,
1100                BinOp::Mul => va * vb,
1101                BinOp::Div => va / vb,
1102                BinOp::Pow => va.powf(vb),
1103                BinOp::Atan2 => va.atan2(vb),
1104            }
1105        }
1106        Expr::Unary(op, a) => {
1107            let va = eval_expr(a, x);
1108            match op {
1109                UnaryOp::Neg => -va,
1110                UnaryOp::Sqrt => va.sqrt(),
1111                UnaryOp::Log => va.ln(),
1112                UnaryOp::Log10 => va.log10(),
1113                UnaryOp::Exp => va.exp(),
1114                UnaryOp::Abs => va.abs(),
1115                UnaryOp::Sin => va.sin(),
1116                UnaryOp::Cos => va.cos(),
1117                UnaryOp::Tan => va.tan(),
1118                UnaryOp::Atan => va.atan(),
1119                UnaryOp::Acos => va.acos(),
1120                UnaryOp::Sinh => va.sinh(),
1121                UnaryOp::Cosh => va.cosh(),
1122                UnaryOp::Tanh => va.tanh(),
1123                UnaryOp::Asin => va.asin(),
1124                UnaryOp::Acosh => va.acosh(),
1125                UnaryOp::Asinh => va.asinh(),
1126                UnaryOp::Atanh => va.atanh(),
1127            }
1128        }
1129        Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
1130        Expr::MinList(args) => args
1131            .iter()
1132            .map(|a| eval_expr(a, x))
1133            .fold(Number::INFINITY, Number::min),
1134        Expr::MaxList(args) => args
1135            .iter()
1136            .map(|a| eval_expr(a, x))
1137            .fold(Number::NEG_INFINITY, Number::max),
1138        Expr::Compare(op, a, b) => {
1139            let va = eval_expr(a, x);
1140            let vb = eval_expr(b, x);
1141            let truth = match op {
1142                CmpOp::Lt => va < vb,
1143                CmpOp::Le => va <= vb,
1144                CmpOp::Eq => va == vb,
1145                CmpOp::Ge => va >= vb,
1146                CmpOp::Gt => va > vb,
1147                CmpOp::Ne => va != vb,
1148            };
1149            if truth {
1150                1.0
1151            } else {
1152                0.0
1153            }
1154        }
1155        Expr::And(a, b) => {
1156            if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
1157                1.0
1158            } else {
1159                0.0
1160            }
1161        }
1162        Expr::Or(a, b) => {
1163            if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
1164                1.0
1165            } else {
1166                0.0
1167            }
1168        }
1169        Expr::Not(a) => {
1170            if eval_expr(a, x) == 0.0 {
1171                1.0
1172            } else {
1173                0.0
1174            }
1175        }
1176        Expr::Cond { cond, then_, else_ } => {
1177            if eval_expr(cond, x) != 0.0 {
1178                eval_expr(then_, x)
1179            } else {
1180                eval_expr(else_, x)
1181            }
1182        }
1183        Expr::Cse(body) => eval_expr(body, x),
1184        Expr::Funcall { .. } => panic!(
1185            "eval_expr: AMPL imported function called without an external resolver; \
1186             evaluate through the tape AD path (Tape::build_with_externals) instead"
1187        ),
1188    }
1189}
1190
1191/// Index of the active operand of an n-ary min (`want_min = true`) or
1192/// max (`want_min = false`) list at point `x`: the smallest / largest
1193/// value, with ties resolved to the first such operand (the
1194/// conventional subgradient choice). Returns `None` for an empty list.
1195fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
1196    let mut best: Option<(usize, Number)> = None;
1197    for (i, a) in args.iter().enumerate() {
1198        let v = eval_expr(a, x);
1199        match best {
1200            None => best = Some((i, v)),
1201            Some((_, bv)) => {
1202                // Strict comparison keeps the FIRST extremal operand on
1203                // ties, matching the subgradient convention used by Abs
1204                // and Select elsewhere in the tape.
1205                if (want_min && v < bv) || (!want_min && v > bv) {
1206                    best = Some((i, v));
1207                }
1208            }
1209        }
1210    }
1211    best.map(|(i, _)| i)
1212}
1213
1214/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
1215pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
1216    match e {
1217        Expr::Const(_) => {}
1218        Expr::Var(i) => grad[*i] += seed,
1219        Expr::Binary(op, a, b) => {
1220            let va = eval_expr(a, x);
1221            let vb = eval_expr(b, x);
1222            match op {
1223                BinOp::Add => {
1224                    grad_expr(a, x, seed, grad);
1225                    grad_expr(b, x, seed, grad);
1226                }
1227                BinOp::Sub => {
1228                    grad_expr(a, x, seed, grad);
1229                    grad_expr(b, x, -seed, grad);
1230                }
1231                BinOp::Mul => {
1232                    grad_expr(a, x, seed * vb, grad);
1233                    grad_expr(b, x, seed * va, grad);
1234                }
1235                BinOp::Div => {
1236                    grad_expr(a, x, seed / vb, grad);
1237                    grad_expr(b, x, -seed * va / (vb * vb), grad);
1238                }
1239                BinOp::Pow => {
1240                    // d/da: b * a^(b-1)
1241                    let dpa = vb * va.powf(vb - 1.0);
1242                    grad_expr(a, x, seed * dpa, grad);
1243                    // d/db: a^b * ln(a) (only valid for a>0; simple branch)
1244                    if va > 0.0 {
1245                        let dpb = va.powf(vb) * va.ln();
1246                        grad_expr(b, x, seed * dpb, grad);
1247                    }
1248                }
1249                BinOp::Atan2 => {
1250                    // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
1251                    let d = va * va + vb * vb;
1252                    grad_expr(a, x, seed * vb / d, grad);
1253                    grad_expr(b, x, -seed * va / d, grad);
1254                }
1255            }
1256        }
1257        Expr::Unary(op, a) => {
1258            let va = eval_expr(a, x);
1259            let d = match op {
1260                UnaryOp::Neg => -1.0,
1261                UnaryOp::Sqrt => 0.5 / va.sqrt(),
1262                UnaryOp::Log => 1.0 / va,
1263                UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
1264                UnaryOp::Exp => va.exp(),
1265                UnaryOp::Abs => {
1266                    if va > 0.0 {
1267                        1.0
1268                    } else if va < 0.0 {
1269                        -1.0
1270                    } else {
1271                        0.0
1272                    }
1273                }
1274                UnaryOp::Sin => va.cos(),
1275                UnaryOp::Cos => -va.sin(),
1276                UnaryOp::Tan => {
1277                    let t = va.tan();
1278                    1.0 + t * t
1279                }
1280                UnaryOp::Atan => 1.0 / (1.0 + va * va),
1281                UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
1282                UnaryOp::Sinh => va.cosh(),
1283                UnaryOp::Cosh => va.sinh(),
1284                UnaryOp::Tanh => {
1285                    let t = va.tanh();
1286                    1.0 - t * t
1287                }
1288                UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
1289                UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
1290                UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
1291                UnaryOp::Atanh => 1.0 / (1.0 - va * va),
1292            };
1293            grad_expr(a, x, seed * d, grad);
1294        }
1295        Expr::Sum(args) => {
1296            for arg in args {
1297                grad_expr(arg, x, seed, grad);
1298            }
1299        }
1300        // min/max are piecewise linear: the seed flows only through the
1301        // currently-active (smallest / largest) operand — a subgradient.
1302        // Ties resolve to the first such operand. Empty list: no operand,
1303        // no derivative (matches the ±inf eval fold).
1304        Expr::MinList(args) => {
1305            if let Some(k) = argmin_argmax(args, x, true) {
1306                grad_expr(&args[k], x, seed, grad);
1307            }
1308        }
1309        Expr::MaxList(args) => {
1310            if let Some(k) = argmin_argmax(args, x, false) {
1311                grad_expr(&args[k], x, seed, grad);
1312            }
1313        }
1314        // Comparisons and logical connectives are piecewise constant:
1315        // zero derivative, so no seed propagates into their operands.
1316        Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
1317        // if-then-else: differentiate only the active branch. The
1318        // branch-switch discontinuity contributes no derivative.
1319        Expr::Cond { cond, then_, else_ } => {
1320            if eval_expr(cond, x) != 0.0 {
1321                grad_expr(then_, x, seed, grad);
1322            } else {
1323                grad_expr(else_, x, seed, grad);
1324            }
1325        }
1326        Expr::Cse(body) => grad_expr(body, x, seed, grad),
1327        Expr::Funcall { .. } => {
1328            panic!("grad_expr: AMPL imported function called without an external resolver")
1329        }
1330    }
1331}
1332
1333/// Walk `e` and insert every `Var(i)` index into `out`.
1334pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
1335    match e {
1336        Expr::Const(_) => {}
1337        Expr::Var(i) => {
1338            out.insert(*i);
1339        }
1340        Expr::Binary(_, a, b) => {
1341            collect_vars(a, out);
1342            collect_vars(b, out);
1343        }
1344        Expr::Unary(_, a) => collect_vars(a, out),
1345        Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1346            for a in args {
1347                collect_vars(a, out);
1348            }
1349        }
1350        // Collect from every child, including the condition: even
1351        // though the comparison/branch-test contributes no derivative,
1352        // the variables it reads are genuinely "used" by the problem,
1353        // and being conservative here only ever adds structural zeros
1354        // to the Jacobian/Hessian (never drops a real nonzero).
1355        Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1356            collect_vars(a, out);
1357            collect_vars(b, out);
1358        }
1359        Expr::Not(a) => collect_vars(a, out),
1360        Expr::Cond { cond, then_, else_ } => {
1361            collect_vars(cond, out);
1362            collect_vars(then_, out);
1363            collect_vars(else_, out);
1364        }
1365        Expr::Cse(body) => collect_vars(body, out),
1366        Expr::Funcall { args, .. } => {
1367            for a in args {
1368                if let FuncallArg::Real(e) = a {
1369                    collect_vars(e, out);
1370                }
1371            }
1372        }
1373    }
1374}
1375
1376// --------------------------------------------------------------------
1377// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
1378// Jacobian, and Hessian. Built once at construction; every solve-time
1379// callback is a tape sweep, no expression-tree recursion.
1380// --------------------------------------------------------------------
1381
1382/// Per-color decoding instruction for `eval_h` Hessian-coloring.
1383/// After a directional Hessian-vector product `compressed = H · s_c`,
1384/// the entry at row `row` came uniquely from column `col` (because
1385/// no two columns of color `c` share any nonzero row), so we
1386/// scatter `compressed[row]` into `values[hess_idx]`.
1387#[derive(Debug, Clone)]
1388struct ColorWrite {
1389    row: u32,
1390    hess_idx: u32,
1391}
1392
1393#[derive(Debug)]
1394pub struct NlTnlp {
1395    prob: NlProblem,
1396    /// Per-summand objective tapes (one `Tape` per top-level
1397    /// summand after `split_top_sums`).
1398    obj_tapes: Vec<Tape>,
1399    /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
1400    /// one `Tape` per summand of constraint `i`.
1401    con_tapes: Vec<Vec<Tape>>,
1402    /// Lower-triangle Hessian sparsity (row >= col), one entry per
1403    /// structurally nonzero second derivative in the Lagrangian.
1404    h_irow: Vec<i32>,
1405    h_jcol: Vec<i32>,
1406    /// Per-row sorted variable indices for the constraint Jacobian.
1407    jac_cols: Vec<Vec<usize>>,
1408    jac_nnz: usize,
1409    /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
1410    /// is in color `c`, else `0.0`. Each color is a set of
1411    /// variables whose Hessian columns have pairwise-disjoint
1412    /// nonzero rows; one directional H·s product per color
1413    /// recovers all those columns simultaneously. Dense for
1414    /// O(1) lookup in the per-op forward tangent.
1415    seeds: Vec<Vec<f64>>,
1416    /// Per-color decoding table: for each `(row, hess_idx)` entry,
1417    /// scatter `compressed_c[row] -> values[hess_idx]` after the
1418    /// per-color directional product.
1419    decoding: Vec<Vec<ColorWrite>>,
1420    /// For each objective tape: the distinct colors of vars it
1421    /// references. Lets us skip tape × color pairs where the tape
1422    /// has zero overlap with the color's seed.
1423    obj_tape_colors: Vec<Vec<u32>>,
1424    /// Same as `obj_tape_colors` but per constraint × summand.
1425    con_tape_colors: Vec<Vec<Vec<u32>>>,
1426    final_x: Option<Vec<Number>>,
1427    final_obj: Number,
1428    /// Per-row Jacobian accumulator (length n).
1429    scratch_row_grad: Vec<f64>,
1430    /// Scratch buffers for `Tape::hessian_directional` (each sized
1431    /// to `max_tape_n`).
1432    vals_scratch: Vec<f64>,
1433    dot_scratch: Vec<f64>,
1434    adj_scratch: Vec<f64>,
1435    adj_dot_scratch: Vec<f64>,
1436    /// Per-color compressed Hessian-vector results, sized to
1437    /// `prob.n`. Reused across `eval_h` calls but allocated once.
1438    compressed: Vec<Vec<f64>>,
1439}
1440
1441// ---------------------------------------------------------------------
1442// Human-readable equation rendering (`print equation` in the debugger).
1443//
1444// Turns a parsed constraint back into infix text using the model's
1445// variable / constraint names, so the debugger can show the actual
1446// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
1447// bare row index. This is the "print the specific equation, with
1448// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
1449// argue makes equation-oriented model diagnostics actionable.
1450//
1451// The renderer is intentionally separate from the evaluation `Tape`:
1452// tapes are lossy for display (CSEs flattened, externals opaque),
1453// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
1454// ---------------------------------------------------------------------
1455
1456/// Binding strength for parenthesization. Higher binds tighter.
1457const P_ADD: u8 = 10;
1458const P_MUL: u8 = 20;
1459const P_NEG: u8 = 30;
1460const P_POW: u8 = 40;
1461const P_ATOM: u8 = 100;
1462
1463/// Format a numeric literal compactly: integers without a trailing `.0`,
1464/// everything else via the shortest round-tripping `f64` form.
1465fn fmt_num(x: Number) -> String {
1466    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
1467        format!("{}", x as i64)
1468    } else {
1469        format!("{x}")
1470    }
1471}
1472
1473/// Display label for variable `i`: its `.col` name when present, else
1474/// `x[i]`.
1475fn var_label(i: usize, var_names: &[String]) -> String {
1476    match var_names.get(i) {
1477        Some(s) if !s.is_empty() => s.clone(),
1478        _ => format!("x[{i}]"),
1479    }
1480}
1481
1482/// Precedence of an expression's top operator (for child wrapping).
1483fn expr_prec(e: &Expr) -> u8 {
1484    match e {
1485        Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
1486        Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
1487        Expr::Unary(UnaryOp::Neg, _) => P_NEG,
1488        Expr::Binary(BinOp::Pow, ..) => P_POW,
1489        Expr::Cse(inner) => expr_prec(inner),
1490        // Everything else renders as an atom / `f(...)` form.
1491        _ => P_ATOM,
1492    }
1493}
1494
1495/// Render `e`, wrapping in parentheses iff its precedence is looser than
1496/// `min_prec`.
1497fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
1498    let s = render_expr(e, vn, funcs);
1499    if expr_prec(e) < min_prec {
1500        format!("({s})")
1501    } else {
1502        s
1503    }
1504}
1505
1506fn unary_name(op: UnaryOp) -> &'static str {
1507    match op {
1508        UnaryOp::Neg => "-",
1509        UnaryOp::Sqrt => "sqrt",
1510        UnaryOp::Log => "log",
1511        UnaryOp::Exp => "exp",
1512        UnaryOp::Abs => "abs",
1513        UnaryOp::Sin => "sin",
1514        UnaryOp::Cos => "cos",
1515        UnaryOp::Log10 => "log10",
1516        UnaryOp::Tan => "tan",
1517        UnaryOp::Atan => "atan",
1518        UnaryOp::Acos => "acos",
1519        UnaryOp::Sinh => "sinh",
1520        UnaryOp::Cosh => "cosh",
1521        UnaryOp::Tanh => "tanh",
1522        UnaryOp::Asin => "asin",
1523        UnaryOp::Acosh => "acosh",
1524        UnaryOp::Asinh => "asinh",
1525        UnaryOp::Atanh => "atanh",
1526    }
1527}
1528
1529fn cmp_sym(op: CmpOp) -> &'static str {
1530    match op {
1531        CmpOp::Lt => "<",
1532        CmpOp::Le => "<=",
1533        CmpOp::Eq => "==",
1534        CmpOp::Ge => ">=",
1535        CmpOp::Gt => ">",
1536        CmpOp::Ne => "!=",
1537    }
1538}
1539
1540/// Append an additive sub-term with a tidy sign: a rendered term that
1541/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
1542/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
1543/// the term is itself a sum. The first term is emitted verbatim.
1544fn push_additive(out: &mut String, rendered: &str, first: bool) {
1545    if first {
1546        out.push_str(rendered);
1547    } else if let Some(rest) = rendered.strip_prefix('-') {
1548        out.push_str(" - ");
1549        out.push_str(rest);
1550    } else {
1551        out.push_str(" + ");
1552        out.push_str(rendered);
1553    }
1554}
1555
1556/// Render an [`Expr`] DAG to infix text using model names.
1557fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
1558    match e {
1559        Expr::Const(c) => fmt_num(*c),
1560        Expr::Var(i) => var_label(*i, vn),
1561        Expr::Binary(op, l, r) => match op {
1562            BinOp::Add => {
1563                let mut s = render_prec(l, P_ADD, vn, funcs);
1564                push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
1565                s
1566            }
1567            // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
1568            BinOp::Sub => format!(
1569                "{} - {}",
1570                render_prec(l, P_ADD, vn, funcs),
1571                render_prec(r, P_ADD + 1, vn, funcs)
1572            ),
1573            BinOp::Mul => format!(
1574                "{}*{}",
1575                render_prec(l, P_MUL, vn, funcs),
1576                render_prec(r, P_MUL, vn, funcs)
1577            ),
1578            BinOp::Div => format!(
1579                "{}/{}",
1580                render_prec(l, P_MUL, vn, funcs),
1581                render_prec(r, P_MUL + 1, vn, funcs)
1582            ),
1583            // Pow is right-associative: tighten the left operand instead.
1584            BinOp::Pow => format!(
1585                "{}^{}",
1586                render_prec(l, P_POW + 1, vn, funcs),
1587                render_prec(r, P_POW, vn, funcs)
1588            ),
1589            BinOp::Atan2 => format!(
1590                "atan2({}, {})",
1591                render_expr(l, vn, funcs),
1592                render_expr(r, vn, funcs)
1593            ),
1594        },
1595        Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
1596        Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
1597        Expr::Sum(xs) => {
1598            if xs.is_empty() {
1599                "0".to_string()
1600            } else {
1601                let mut s = String::new();
1602                for (k, x) in xs.iter().enumerate() {
1603                    push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
1604                }
1605                s
1606            }
1607        }
1608        Expr::Cse(inner) => render_expr(inner, vn, funcs),
1609        Expr::Funcall { id, args } => {
1610            let name = funcs
1611                .iter()
1612                .find(|f| f.id == *id)
1613                .map(|f| f.name.clone())
1614                .unwrap_or_else(|| format!("extern#{id}"));
1615            let parts: Vec<String> = args
1616                .iter()
1617                .map(|a| match a {
1618                    FuncallArg::Real(x) => render_expr(x, vn, funcs),
1619                    FuncallArg::Str(s) => format!("{s:?}"),
1620                })
1621                .collect();
1622            format!("{name}({})", parts.join(", "))
1623        }
1624        Expr::Compare(op, a, b) => format!(
1625            "({} {} {})",
1626            render_expr(a, vn, funcs),
1627            cmp_sym(*op),
1628            render_expr(b, vn, funcs)
1629        ),
1630        Expr::And(a, b) => format!(
1631            "({} && {})",
1632            render_expr(a, vn, funcs),
1633            render_expr(b, vn, funcs)
1634        ),
1635        Expr::Or(a, b) => format!(
1636            "({} || {})",
1637            render_expr(a, vn, funcs),
1638            render_expr(b, vn, funcs)
1639        ),
1640        Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
1641        Expr::Cond { cond, then_, else_ } => format!(
1642            "if({}, {}, {})",
1643            render_expr(cond, vn, funcs),
1644            render_expr(then_, vn, funcs),
1645            render_expr(else_, vn, funcs)
1646        ),
1647        Expr::MinList(xs) => format!(
1648            "min({})",
1649            xs.iter()
1650                .map(|x| render_expr(x, vn, funcs))
1651                .collect::<Vec<_>>()
1652                .join(", ")
1653        ),
1654        Expr::MaxList(xs) => format!(
1655            "max({})",
1656            xs.iter()
1657                .map(|x| render_expr(x, vn, funcs))
1658                .collect::<Vec<_>>()
1659                .join(", ")
1660        ),
1661    }
1662}
1663
1664/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
1665/// `a + -2*b`). Returns `""` when there are no linear terms.
1666fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
1667    let mut out = String::new();
1668    // The `.nl` linear part carries an entry for every variable in the
1669    // row's Jacobian, including a 0 coefficient for variables that appear
1670    // only *nonlinearly* (they're rendered in the nonlinear part). Skip
1671    // those zeros so the equation reads as written, not as a sparsity map.
1672    let mut first = true;
1673    for (var, coef) in linear {
1674        if *coef == 0.0 {
1675            continue;
1676        }
1677        let neg = *coef < 0.0;
1678        let mag = coef.abs();
1679        let term = if mag == 1.0 {
1680            var_label(*var, vn)
1681        } else {
1682            format!("{}*{}", fmt_num(mag), var_label(*var, vn))
1683        };
1684        if first {
1685            if neg {
1686                out.push('-');
1687            }
1688            out.push_str(&term);
1689            first = false;
1690        } else {
1691            out.push_str(if neg { " - " } else { " + " });
1692            out.push_str(&term);
1693        }
1694    }
1695    out
1696}
1697
1698/// Render the constraint body (linear + nonlinear parts combined).
1699fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
1700    let mut s = render_linear(linear, &prob.var_names);
1701    let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
1702    if !nl_is_zero {
1703        let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
1704        if s.is_empty() {
1705            s = nl;
1706        } else {
1707            push_additive(&mut s, &nl, false);
1708        }
1709    }
1710    if s.is_empty() {
1711        s = "0".to_string();
1712    }
1713    s
1714}
1715
1716/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
1717/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
1718/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
1719pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
1720    let body = render_body(&prob.con_linear[k], &prob.con_nonlinear[k], prob);
1721    let lo = prob.g_l[k];
1722    let hi = prob.g_u[k];
1723    const INF: Number = 1.0e19;
1724    let has_lo = lo > -INF;
1725    let has_hi = hi < INF;
1726    match (has_lo, has_hi) {
1727        (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
1728        (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
1729        (true, false) => format!("{body} >= {}", fmt_num(lo)),
1730        (false, true) => format!("{body} <= {}", fmt_num(hi)),
1731        (false, false) => format!("{body}  (free)"),
1732    }
1733}
1734
1735/// Render every constraint to text, index-aligned to `g` (original `.nl`
1736/// row order). Used to build the debugger's static equation book.
1737pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
1738    (0..prob.m)
1739        .map(|k| render_constraint_equation(prob, k))
1740        .collect()
1741}
1742
1743/// Structural sparsity of the constraint Jacobian as flat 0-based
1744/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
1745/// structurally depends on — the union of its linear support and the
1746/// `Var(i)` indices appearing anywhere in its nonlinear tree
1747/// ([`collect_vars`]). Sorted and deduplicated within each row.
1748///
1749/// This is the input to the debugger's Dulmage–Mendelsohn
1750/// structural-rank check (`diagnose`), which names the over-determined
1751/// (candidate redundant / inconsistent) equations and under-determined
1752/// variables. Naming the dependent rows — rather than reporting
1753/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
1754/// equation-oriented model debugging. See
1755/// <https://doi.org/10.69997/sct.147875>.
1756pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
1757    let mut irow: Vec<Index> = Vec::new();
1758    let mut jcol: Vec<Index> = Vec::new();
1759    let mut support: BTreeSet<usize> = BTreeSet::new();
1760    for k in 0..prob.m {
1761        support.clear();
1762        for &(j, _coef) in &prob.con_linear[k] {
1763            support.insert(j);
1764        }
1765        collect_vars(&prob.con_nonlinear[k], &mut support);
1766        for &j in &support {
1767            irow.push(k as Index);
1768            jcol.push(j as Index);
1769        }
1770    }
1771    (irow, jcol)
1772}
1773
1774/// Flatten an additive expression tree into independent summand
1775/// expressions, each of which becomes its own Hessian tape.
1776///
1777/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
1778/// each summand tape once *per color the summand touches*, so the
1779/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
1780/// small (few variables → few colors) is what makes a sparse Hessian
1781/// cheap. A single fused tape spanning all `n` variables, by
1782/// contrast, is walked once per color → `O(n · tape_len)`, which on a
1783/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
1784/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
1785///
1786/// We therefore descend through the *affine* envelope of the sum, not
1787/// just `+`/`Sum`:
1788///
1789///   * `Neg(x)`            → split `x`, negate each summand
1790///   * `Sub(l, r)`         → split `l`; split `r`, negate each summand
1791///   * `c * x` / `x * c`   → split `x`, scale each summand by `c`
1792///   * `x / c`             → split `x`, scale each summand by `1/c`
1793///
1794/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
1795/// least-squares / max-entropy shapes) still decomposes to its leaf
1796/// terms instead of collapsing into one giant tape. The carried
1797/// `factor` is materialised onto each leaf only when it differs from
1798/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
1799/// is unchanged and the per-summand op count grows by at most one.
1800fn split_top_sums(expr: &Expr) -> Vec<Expr> {
1801    let mut out = Vec::new();
1802    fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
1803        if factor == 1.0 {
1804            out.push(e.clone());
1805        } else if factor == -1.0 {
1806            out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
1807        } else {
1808            out.push(Expr::Binary(
1809                BinOp::Mul,
1810                Box::new(Expr::Const(factor)),
1811                Box::new(e.clone()),
1812            ));
1813        }
1814    }
1815    fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
1816        match e {
1817            Expr::Sum(terms) => {
1818                for t in terms {
1819                    go(t, factor, out);
1820                }
1821            }
1822            Expr::Binary(BinOp::Add, l, r) => {
1823                go(l, factor, out);
1824                go(r, factor, out);
1825            }
1826            Expr::Binary(BinOp::Sub, l, r) => {
1827                go(l, factor, out);
1828                go(r, -factor, out);
1829            }
1830            Expr::Unary(UnaryOp::Neg, x) => {
1831                go(x, -factor, out);
1832            }
1833            // Affine scaling: distribute a constant coefficient into
1834            // the summands so a leading `c·(Σ …)` still splits.
1835            Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
1836                (Expr::Const(c), _) => go(r, factor * c, out),
1837                (_, Expr::Const(c)) => go(l, factor * c, out),
1838                _ => push_leaf(e, factor, out),
1839            },
1840            Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
1841                Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
1842                _ => push_leaf(e, factor, out),
1843            },
1844            _ => push_leaf(e, factor, out),
1845        }
1846    }
1847    go(expr, 1.0, &mut out);
1848    if out.is_empty() {
1849        out.push(Expr::Const(0.0));
1850    }
1851    out
1852}
1853
1854/// Greedy column coloring of a symmetric sparsity pattern stored
1855/// as lower-triangle pairs.
1856///
1857/// Builds the column-intersection graph: columns `c1` and `c2` are
1858/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
1859/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
1860/// satisfies the direct-recovery condition for symmetric Hessians
1861/// (Coleman-Moré): for any color, the columns it contains have
1862/// pairwise disjoint row supports, so a single H·s product
1863/// recovers them all unambiguously.
1864///
1865/// Returns `(var_color, n_colors)` where `var_color[k]` is the
1866/// color assigned to variable `k`, or `u32::MAX` for variables
1867/// not in any Hessian pair (they contribute nothing and don't
1868/// need a color).
1869fn greedy_hessian_coloring(n: usize, lower_pairs: &[(usize, usize)]) -> (Vec<u32>, usize) {
1870    if n == 0 {
1871        return (Vec::new(), 0);
1872    }
1873
1874    // For each variable k, list of rows in which column k has a
1875    // nonzero in the FULL (symmetric) Hessian. Built from lower
1876    // pairs: (i, j) with i >= j contributes row i to column j and
1877    // row j to column i (when i != j); diagonals contribute once.
1878    let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
1879    let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
1880    for &(i, j) in lower_pairs {
1881        col_rows[j].push(i as u32);
1882        row_cols[i].push(j as u32);
1883        if i != j {
1884            col_rows[i].push(j as u32);
1885            row_cols[j].push(i as u32);
1886        }
1887    }
1888
1889    let mut var_color = vec![u32::MAX; n];
1890    let mut forbidden = vec![u32::MAX; n + 1];
1891    let mut n_colors: u32 = 0;
1892
1893    for j in 0..n {
1894        // Variable `j` has no Hessian entries → skip (no color).
1895        if col_rows[j].is_empty() {
1896            continue;
1897        }
1898        // Mark colors used by any column sharing a row with `j`.
1899        // Row-of-col -> col-in-row visit pattern collects all
1900        // distance-1 neighbors in the column-intersection graph.
1901        for &r in &col_rows[j] {
1902            for &c in &row_cols[r as usize] {
1903                if c as usize == j {
1904                    continue;
1905                }
1906                let cc = var_color[c as usize];
1907                if cc != u32::MAX {
1908                    forbidden[cc as usize] = j as u32;
1909                }
1910            }
1911        }
1912        // First color not stamped with `j as u32`.
1913        let mut chosen: u32 = 0;
1914        while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
1915            chosen += 1;
1916        }
1917        var_color[j] = chosen;
1918        if chosen + 1 > n_colors {
1919            n_colors = chosen + 1;
1920        }
1921    }
1922
1923    (var_color, n_colors as usize)
1924}
1925
1926impl NlTnlp {
1927    /// Build the TNLP, panicking if AMPL external-function resolution fails.
1928    ///
1929    /// Kept for the many infallible call sites (CLI, tests) that operate on
1930    /// `.nl` models known to need no external libraries. Surfaces that can be
1931    /// handed an arbitrary user model — notably the Python `read_nl` binding —
1932    /// must call [`Self::try_new`] instead so a missing `$AMPLFUNC` library
1933    /// becomes a catchable error rather than an uncatchable panic across the
1934    /// pyo3 boundary.
1935    pub fn new(prob: NlProblem) -> Self {
1936        Self::try_new(prob)
1937            .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
1938    }
1939
1940    /// Build the TNLP, returning an error (instead of panicking) when AMPL
1941    /// imported functions named by the model can't be resolved — e.g.
1942    /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
1943    /// referenced function id isn't registered by any loaded library.
1944    pub fn try_new(prob: NlProblem) -> Result<Self, String> {
1945        // Resolve any AMPL imported (external) functions. Walk every
1946        // nonlinear expression to collect the funcall ids actually
1947        // referenced; load the libraries named in $AMPLFUNC and bind
1948        // each id to its (library, registered-name) pair so the tape
1949        // builder can emit live `TapeOp::Funcall` ops.
1950        let mut referenced: BTreeSet<usize> = BTreeSet::new();
1951        super::nl_external::collect_funcall_ids(&prob.obj_nonlinear, &mut referenced);
1952        for c in &prob.con_nonlinear {
1953            super::nl_external::collect_funcall_ids(c, &mut referenced);
1954        }
1955        let resolver = if referenced.is_empty() {
1956            super::nl_external::ExternalResolver::default()
1957        } else {
1958            super::nl_external::ExternalResolver::build_for_problem(
1959                &prob.imported_funcs,
1960                &referenced,
1961            )?
1962        };
1963
1964        // Flatten objective and each constraint into independent
1965        // summands. Each summand becomes its own `Tape` (CSE bodies
1966        // are deduplicated within a tape via Rc identity in
1967        // `Tape::build`; bodies shared across summands are
1968        // duplicated, which we accept as a simplicity tradeoff).
1969        let obj_summands = split_top_sums(&prob.obj_nonlinear);
1970        let obj_tapes: Vec<Tape> = obj_summands
1971            .iter()
1972            .map(|e| Tape::build_with_externals(e, &resolver))
1973            .collect();
1974
1975        let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
1976        for k in 0..prob.m {
1977            let summands = split_top_sums(&prob.con_nonlinear[k]);
1978            con_tapes.push(
1979                summands
1980                    .iter()
1981                    .map(|e| Tape::build_with_externals(e, &resolver))
1982                    .collect(),
1983            );
1984        }
1985
1986        // Hessian-of-Lagrangian sparsity: union of each tape's own
1987        // structural Hessian sparsity.
1988        let mut pairs: BTreeSet<(usize, usize)> = BTreeSet::new();
1989        for t in &obj_tapes {
1990            pairs.extend(t.hessian_sparsity());
1991        }
1992        for row in &con_tapes {
1993            for t in row {
1994                pairs.extend(t.hessian_sparsity());
1995            }
1996        }
1997        let mut h_irow = Vec::with_capacity(pairs.len());
1998        let mut h_jcol = Vec::with_capacity(pairs.len());
1999        let mut hess_map = HashMap::with_capacity(pairs.len());
2000        for (k, (hi, lo)) in pairs.iter().enumerate() {
2001            h_irow.push(*hi as i32);
2002            h_jcol.push(*lo as i32);
2003            hess_map.insert((*hi, *lo), k);
2004        }
2005
2006        // Hessian column coloring. The chromatic number of the
2007        // column-intersection graph bounds how many directional
2008        // Hessian-vector products we need per `eval_h` call —
2009        // typically O(stencil) for PDE-mesh problems.
2010        let lower_pairs: Vec<(usize, usize)> = pairs.iter().copied().collect();
2011        let (var_color, n_colors) = greedy_hessian_coloring(prob.n, &lower_pairs);
2012
2013        // Per-color seed vectors (dense for O(1) Var lookup in
2014        // `Tape::hessian_directional`).
2015        let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
2016        for (k, &c) in var_color.iter().enumerate() {
2017            if c != u32::MAX {
2018                seeds[c as usize][k] = 1.0;
2019            }
2020        }
2021
2022        // Per-color decoding table. For each lower-tri pair (i, j)
2023        // with i >= j, the entry belongs to column j's color: after
2024        // computing compressed_{c_j} = (H · s_{c_j}), the value at
2025        // row i is exactly H[i, j] (coloring guarantees no other
2026        // column in c_j has a nonzero at row i).
2027        let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
2028        for (&(i, j), &idx) in hess_map.iter() {
2029            let c = var_color[j];
2030            debug_assert!(
2031                c != u32::MAX,
2032                "column {j} has Hessian pair {idx} but no color"
2033            );
2034            decoding[c as usize].push(ColorWrite {
2035                row: i as u32,
2036                hess_idx: idx as u32,
2037            });
2038        }
2039
2040        // Per-tape distinct color set: for each tape, the colors
2041        // its variables fall into. `eval_h` loops over only these
2042        // (tape, color) pairs instead of n_tapes × n_colors.
2043        let tape_colors = |t: &Tape| -> Vec<u32> {
2044            let mut s: BTreeSet<u32> = BTreeSet::new();
2045            for v in t.variables() {
2046                let c = var_color[v];
2047                if c != u32::MAX {
2048                    s.insert(c);
2049                }
2050            }
2051            s.into_iter().collect()
2052        };
2053        let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
2054        let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
2055            .iter()
2056            .map(|row| row.iter().map(tape_colors).collect())
2057            .collect();
2058
2059        // Per-row Jacobian sparsity = union of tape vars plus
2060        // linear-segment vars.
2061        let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
2062        let mut jac_nnz = 0;
2063        for i in 0..prob.m {
2064            let mut set: BTreeSet<usize> = BTreeSet::new();
2065            for t in &con_tapes[i] {
2066                for v in t.variables() {
2067                    set.insert(v);
2068                }
2069            }
2070            for (v, _) in &prob.con_linear[i] {
2071                set.insert(*v);
2072            }
2073            let cols: Vec<usize> = set.into_iter().collect();
2074            jac_nnz += cols.len();
2075            jac_cols.push(cols);
2076        }
2077
2078        let mut max_tape_n: usize = 0;
2079        for t in &obj_tapes {
2080            max_tape_n = max_tape_n.max(t.ops.len());
2081        }
2082        for row in &con_tapes {
2083            for t in row {
2084                max_tape_n = max_tape_n.max(t.ops.len());
2085            }
2086        }
2087
2088        if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
2089            let n_obj = obj_tapes.len();
2090            let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
2091            let total = n_obj + n_con;
2092            let mut sum_ops: usize = 0;
2093            for t in &obj_tapes {
2094                sum_ops += t.ops.len();
2095            }
2096            for row in &con_tapes {
2097                for t in row {
2098                    sum_ops += t.ops.len();
2099                }
2100            }
2101            let t = total.max(1);
2102            let nnz_h = h_irow.len();
2103            let avg_decode =
2104                decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
2105            eprintln!(
2106                "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
2107                 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
2108                 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h}",
2109                sum_ops as f64 / t as f64,
2110            );
2111        }
2112
2113        let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
2114
2115        Ok(Self {
2116            prob,
2117            obj_tapes,
2118            con_tapes,
2119            h_irow,
2120            h_jcol,
2121            jac_cols,
2122            jac_nnz,
2123            seeds,
2124            decoding,
2125            obj_tape_colors,
2126            con_tape_colors,
2127            final_x: None,
2128            final_obj: 0.0,
2129            scratch_row_grad: Vec::new(),
2130            vals_scratch: vec![0.0; max_tape_n],
2131            dot_scratch: vec![0.0; max_tape_n],
2132            adj_scratch: vec![0.0; max_tape_n],
2133            adj_dot_scratch: vec![0.0; max_tape_n],
2134            compressed,
2135        })
2136    }
2137
2138    pub fn final_x(&self) -> Option<&[Number]> {
2139        self.final_x.as_deref()
2140    }
2141
2142    pub fn final_obj(&self) -> Number {
2143        self.final_obj
2144    }
2145}
2146
2147impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
2148    /// Per-`.nl`-row constraint expression tape, with the linear
2149    /// part folded in. Returns `None` for constraints that contribute
2150    /// neither a nonlinear expression nor any linear coefficients
2151    /// (so FBBT skips them — there's nothing to tighten).
2152    fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
2153        let nonlinear = self.prob.con_nonlinear.get(i)?;
2154        let linear = self
2155            .prob
2156            .con_linear
2157            .get(i)
2158            .map(|v| v.as_slice())
2159            .unwrap_or(&[]);
2160        crate::nl_fbbt_translate::translate_constraint(nonlinear, linear)
2161    }
2162
2163    /// Variable name from the sibling `.col` file, if one was loaded.
2164    /// Index is original `.nl` column order.
2165    fn variable_name(&self, i: usize) -> Option<&str> {
2166        self.prob.var_names.get(i).map(String::as_str)
2167    }
2168
2169    /// Constraint name from the sibling `.row` file, if one was loaded.
2170    /// Index is original `.nl` row order.
2171    fn constraint_name(&self, i: usize) -> Option<&str> {
2172        self.prob.con_names.get(i).map(String::as_str)
2173    }
2174}
2175
2176impl TNLP for NlTnlp {
2177    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
2178        Some(NlpInfo {
2179            n: self.prob.n as Index,
2180            m: self.prob.m as Index,
2181            nnz_jac_g: self.jac_nnz as Index,
2182            nnz_h_lag: self.h_irow.len() as Index,
2183            index_style: IndexStyle::C,
2184        })
2185    }
2186
2187    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
2188        b.x_l.copy_from_slice(&self.prob.x_l);
2189        b.x_u.copy_from_slice(&self.prob.x_u);
2190        if !self.prob.g_l.is_empty() {
2191            b.g_l.copy_from_slice(&self.prob.g_l);
2192            b.g_u.copy_from_slice(&self.prob.g_u);
2193        }
2194        true
2195    }
2196
2197    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
2198        sp.x.copy_from_slice(&self.prob.x0);
2199        true
2200    }
2201
2202    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
2203        let mut nl: Number = 0.0;
2204        for t in &self.obj_tapes {
2205            nl += t.eval(x);
2206        }
2207        let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
2208        let v = self.prob.obj_constant + nl + lin;
2209        let signed = if self.prob.minimize { v } else { -v };
2210        Some(signed)
2211    }
2212
2213    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
2214        grad.fill(0.0);
2215        for t in &self.obj_tapes {
2216            t.gradient_seed(x, 1.0, grad);
2217        }
2218        for (i, c) in &self.prob.obj_linear {
2219            grad[*i] += c;
2220        }
2221        if !self.prob.minimize {
2222            for g in grad.iter_mut() {
2223                *g = -*g;
2224            }
2225        }
2226        true
2227    }
2228
2229    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
2230        for i in 0..self.prob.m {
2231            let mut nl: Number = 0.0;
2232            for t in &self.con_tapes[i] {
2233                nl += t.eval(x);
2234            }
2235            let lin: Number = self.prob.con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
2236            g[i] = nl + lin;
2237        }
2238        true
2239    }
2240
2241    fn eval_jac_g(
2242        &mut self,
2243        x: Option<&[Number]>,
2244        _new_x: bool,
2245        mode: SparsityRequest<'_>,
2246    ) -> bool {
2247        match mode {
2248            SparsityRequest::Structure { irow, jcol } => {
2249                let mut k = 0;
2250                for i in 0..self.prob.m {
2251                    for &j in &self.jac_cols[i] {
2252                        irow[k] = i as Index;
2253                        jcol[k] = j as Index;
2254                        k += 1;
2255                    }
2256                }
2257                true
2258            }
2259            SparsityRequest::Values { values } => {
2260                let n = self.prob.n;
2261                let xs = x.unwrap_or(&self.prob.x0);
2262                if self.scratch_row_grad.len() < n {
2263                    self.scratch_row_grad.resize(n, 0.0);
2264                }
2265                let mut k = 0;
2266                for i in 0..self.prob.m {
2267                    for &j in &self.jac_cols[i] {
2268                        self.scratch_row_grad[j] = 0.0;
2269                    }
2270                    for t in &self.con_tapes[i] {
2271                        t.gradient_seed(xs, 1.0, &mut self.scratch_row_grad);
2272                    }
2273                    for &(v, c) in &self.prob.con_linear[i] {
2274                        self.scratch_row_grad[v] += c;
2275                    }
2276                    for &j in &self.jac_cols[i] {
2277                        values[k] = self.scratch_row_grad[j];
2278                        k += 1;
2279                    }
2280                }
2281                true
2282            }
2283        }
2284    }
2285
2286    fn eval_h(
2287        &mut self,
2288        x: Option<&[Number]>,
2289        _new_x: bool,
2290        obj_factor: Number,
2291        lambda: Option<&[Number]>,
2292        _new_lambda: bool,
2293        mode: SparsityRequest<'_>,
2294    ) -> bool {
2295        match mode {
2296            SparsityRequest::Structure { irow, jcol } => {
2297                irow.copy_from_slice(&self.h_irow);
2298                jcol.copy_from_slice(&self.h_jcol);
2299                true
2300            }
2301            SparsityRequest::Values { values } => {
2302                let x = x.unwrap_or(&self.prob.x0);
2303                values.fill(0.0);
2304
2305                let obj_seed = if self.prob.minimize {
2306                    obj_factor
2307                } else {
2308                    -obj_factor
2309                };
2310                // Coloring path. For each (tape, weight) we do
2311                // one forward pass into `vals_scratch`, then one
2312                // forward-tangent+reverse-over-tangent per color
2313                // touched by that tape. Each pass accumulates a
2314                // weighted contribution of (H_tape · seed_c) into
2315                // `compressed[c]`. After all tapes done, we
2316                // decode each color's compressed vector into the
2317                // sparse `values` array.
2318                for buf in &mut self.compressed {
2319                    buf.fill(0.0);
2320                }
2321
2322                if obj_seed != 0.0 {
2323                    for (ti, t) in self.obj_tapes.iter().enumerate() {
2324                        if t.ops.is_empty() {
2325                            continue;
2326                        }
2327                        t.forward_into(x, &mut self.vals_scratch);
2328                        for &c in &self.obj_tape_colors[ti] {
2329                            t.hessian_directional(
2330                                &self.vals_scratch,
2331                                &self.seeds[c as usize],
2332                                obj_seed,
2333                                &mut self.compressed[c as usize],
2334                                &mut self.dot_scratch,
2335                                &mut self.adj_scratch,
2336                                &mut self.adj_dot_scratch,
2337                            );
2338                        }
2339                    }
2340                }
2341
2342                if let Some(lam) = lambda {
2343                    for k in 0..self.prob.m {
2344                        let w = lam[k];
2345                        if w == 0.0 {
2346                            continue;
2347                        }
2348                        for (ti, t) in self.con_tapes[k].iter().enumerate() {
2349                            if t.ops.is_empty() {
2350                                continue;
2351                            }
2352                            t.forward_into(x, &mut self.vals_scratch);
2353                            for &c in &self.con_tape_colors[k][ti] {
2354                                t.hessian_directional(
2355                                    &self.vals_scratch,
2356                                    &self.seeds[c as usize],
2357                                    w,
2358                                    &mut self.compressed[c as usize],
2359                                    &mut self.dot_scratch,
2360                                    &mut self.adj_scratch,
2361                                    &mut self.adj_dot_scratch,
2362                                );
2363                            }
2364                        }
2365                    }
2366                }
2367
2368                // Decode each color's compressed Hessian-vector
2369                // result into the lower-triangle `values` array.
2370                for (c, table) in self.decoding.iter().enumerate() {
2371                    let comp = &self.compressed[c];
2372                    for w in table {
2373                        values[w.hess_idx as usize] += comp[w.row as usize];
2374                    }
2375                }
2376                true
2377            }
2378        }
2379    }
2380
2381    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
2382        self.final_x = Some(sol.x.to_vec());
2383        self.final_obj = sol.obj_value;
2384    }
2385
2386    /// Publish the `.col` / `.row` names (captured at load time) under the
2387    /// conventional `idx_names` metadata key, in original `.nl` order. The
2388    /// adapter permutes these into split space (see
2389    /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
2390    /// near-singular Jacobian row as the `mass_balance` equation rather
2391    /// than "row 3" — the model-vs-index gap Lee et al. (2024,
2392    /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
2393    /// model debugging. Declines (returns false) when the model shipped no
2394    /// name files so callers fall back to index labels.
2395    fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
2396        let mut any = false;
2397        if !self.prob.var_names.is_empty() {
2398            var.strings
2399                .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
2400            any = true;
2401        }
2402        if !self.prob.con_names.is_empty() {
2403            con.strings
2404                .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
2405            any = true;
2406        }
2407        any
2408    }
2409
2410    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
2411        // A row is linear iff its nonlinear-part expression is the
2412        // identity zero left over from initial allocation (post-parse
2413        // identity for "no `C<idx>` segment touched this row").
2414        for (i, t) in types.iter_mut().enumerate() {
2415            *t = match &self.prob.con_nonlinear[i] {
2416                Expr::Const(c) if *c == 0.0 => Linearity::Linear,
2417                _ => Linearity::NonLinear,
2418            };
2419        }
2420        true
2421    }
2422}
2423
2424/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
2425pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
2426    let prob = read_nl_file(path)?;
2427    Ok(Rc::new(RefCell::new(NlTnlp::new(prob))))
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432    use super::*;
2433
2434    /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
2435    /// Header values:
2436    ///   line 2: n=2 m=0 num_obj=1 0 0
2437    ///   line 3: 0 1   (1 nonlinear objective)
2438    ///   line 4: 0 0
2439    ///   line 5: 0 2 0 (nonlinear vars in obj=2)
2440    ///   line 6: 0 0 0 1
2441    ///   line 7: 0 0 0 0 0
2442    ///   line 8: 0 0   (no Jacobian nonzeros, no linear obj)
2443    ///   line 9: 0 0
2444    ///   line 10: 0 0 0 0 0
2445    /// Then `O0 0` followed by an expression tree:
2446    /// `(x0 - 1)^2 + (x1 - 2)^2` =
2447    ///   o0
2448    ///     o5 (o1 v0 n1) n2
2449    ///     o5 (o1 v1 n2) n2
2450    /// Then `b` segment: free for both.
2451    const SIMPLE: &str = "g3 0 1 0
24522 0 1 0 0
24530 1
24540 0
24550 2 0
24560 0 0 1
24570 0 0 0 0
24580 0
24590 0
24600 0 0 0 0
2461O0 0
2462o0
2463o5
2464o1
2465v0
2466n1
2467n2
2468o5
2469o1
2470v1
2471n2
2472n2
2473b
24743
24753
2476";
2477
2478    #[test]
2479    fn parses_simple_quadratic() {
2480        let p = parse_nl_text(SIMPLE).expect("parse");
2481        assert_eq!(p.n, 2);
2482        assert_eq!(p.m, 0);
2483        assert_eq!(p.num_obj, 1);
2484        // f(0,0) = 1 + 4 = 5
2485        let f = eval_expr(&p.obj_nonlinear, &[0.0, 0.0]);
2486        assert!((f - 5.0).abs() < 1e-12);
2487        // f(1,2) = 0
2488        let f = eval_expr(&p.obj_nonlinear, &[1.0, 2.0]);
2489        assert!(f.abs() < 1e-12);
2490    }
2491
2492    #[test]
2493    fn gradient_matches_analytic() {
2494        let p = parse_nl_text(SIMPLE).expect("parse");
2495        let x = [0.5, 1.0];
2496        let mut g = [0.0_f64; 2];
2497        grad_expr(&p.obj_nonlinear, &x, 1.0, &mut g);
2498        // d/dx0 = 2*(x0-1) = -1.0
2499        // d/dx1 = 2*(x1-2) = -2.0
2500        assert!((g[0] - (-1.0)).abs() < 1e-12);
2501        assert!((g[1] - (-2.0)).abs() < 1e-12);
2502    }
2503
2504    /// `min x0^2 + x1^2  s.t.  x0 + x1 = 1`.
2505    /// One equality constraint with a purely linear Jacobian — exercises
2506    /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
2507    /// kind 4).
2508    ///
2509    /// Header layout:
2510    ///   line 1: g3 0 1 0
2511    ///   line 2: 2 1 1 0 0   (n=2, m=1, num_obj=1)
2512    ///   line 3: 0 1         (1 nonlinear obj, 0 nonlinear cons)
2513    ///   line 4: 0 0
2514    ///   line 5: 0 2 0       (nonlinear vars in obj=2)
2515    ///   line 6: 0 0 0 1
2516    ///   line 7: 0 0 0 0 0
2517    ///   line 8: 2 0         (Jacobian nnz=2, no linear obj)
2518    ///   line 9: 0 0
2519    ///   line 10: 0 0 0 0 0
2520    /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
2521    /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
2522    const EQ_LIN: &str = "g3 0 1 0
25232 1 1 0 0
25240 1
25250 0
25260 2 0
25270 0 0 1
25280 0 0 0 0
25292 0
25300 0
25310 0 0 0 0
2532C0
2533n0
2534O0 0
2535o0
2536o5
2537v0
2538n2
2539o5
2540v1
2541n2
2542r
25434 1
2544b
25453
25463
2547k1
25482
2549J0 2
25500 1
25511 1
2552";
2553
2554    #[test]
2555    fn parses_constrained_problem() {
2556        let p = parse_nl_text(EQ_LIN).expect("parse");
2557        assert_eq!(p.n, 2);
2558        assert_eq!(p.m, 1);
2559        // r-segment kind 4 (equality with rhs=1).
2560        assert!((p.g_l[0] - 1.0).abs() < 1e-12);
2561        assert!((p.g_u[0] - 1.0).abs() < 1e-12);
2562        // J-row 0: x0 (coef 1), x1 (coef 1).
2563        assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
2564    }
2565
2566    #[test]
2567    fn constrained_tnlp_eval_g_jac_h() {
2568        let p = parse_nl_text(EQ_LIN).expect("parse");
2569        let mut t = NlTnlp::new(p);
2570        let info = t.get_nlp_info().unwrap();
2571        assert_eq!(info.m, 1);
2572        assert_eq!(info.nnz_jac_g, 2);
2573
2574        // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
2575        let mut g = [0.0_f64; 1];
2576        assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
2577        assert!((g[0] - 0.7).abs() < 1e-12);
2578
2579        // Jacobian structure: row 0, cols [0, 1].
2580        let mut irow = [0_i32; 2];
2581        let mut jcol = [0_i32; 2];
2582        assert!(t.eval_jac_g(
2583            None,
2584            true,
2585            SparsityRequest::Structure {
2586                irow: &mut irow,
2587                jcol: &mut jcol
2588            }
2589        ));
2590        assert_eq!(irow, [0, 0]);
2591        assert_eq!(jcol, [0, 1]);
2592
2593        // Jacobian values: both 1.0.
2594        let mut vals = [0.0_f64; 2];
2595        assert!(t.eval_jac_g(
2596            Some(&[0.3, 0.4]),
2597            true,
2598            SparsityRequest::Values { values: &mut vals }
2599        ));
2600        assert!((vals[0] - 1.0).abs() < 1e-12);
2601        assert!((vals[1] - 1.0).abs() < 1e-12);
2602
2603        // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
2604        // λ contributes nothing because the constraint is linear, and
2605        // x0^2 + x1^2 is separable so there's no (1,0) entry in the
2606        // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
2607        assert_eq!(info.nnz_h_lag, 2);
2608        let mut hirow = [0_i32; 2];
2609        let mut hjcol = [0_i32; 2];
2610        assert!(t.eval_h(
2611            None,
2612            true,
2613            1.0,
2614            None,
2615            true,
2616            SparsityRequest::Structure {
2617                irow: &mut hirow,
2618                jcol: &mut hjcol
2619            }
2620        ));
2621        assert_eq!(hirow, [0, 1]);
2622        assert_eq!(hjcol, [0, 1]);
2623        let mut hvals = [0.0_f64; 2];
2624        assert!(t.eval_h(
2625            Some(&[0.3, 0.4]),
2626            true,
2627            1.0,
2628            Some(&[0.5]),
2629            true,
2630            SparsityRequest::Values { values: &mut hvals }
2631        ));
2632        assert!((hvals[0] - 2.0).abs() < 1e-12);
2633        assert!((hvals[1] - 2.0).abs() < 1e-12);
2634    }
2635
2636    /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
2637    /// encoded as common-subexpression `V2`. Header line 10 declares
2638    /// one obj-only CSE; expression tree references `v2` twice.
2639    const CSE_OBJ: &str = "g3 0 1 0
26402 0 1 0 0
26410 1
26420 0
26430 2 0
26440 0 0 1
26450 0 0 0 0
26460 0
26470 0
26480 1 0 0 0
2649V2 0 0
2650o0
2651v0
2652v1
2653O0 0
2654o0
2655o5
2656v2
2657n2
2658v2
2659b
26603
26613
2662";
2663
2664    #[test]
2665    fn parses_v_segment_cse() {
2666        let p = parse_nl_text(CSE_OBJ).expect("parse");
2667        assert_eq!(p.n, 2);
2668        // f(1,2) = 9 + 3 = 12
2669        let f = eval_expr(&p.obj_nonlinear, &[1.0, 2.0]);
2670        assert!((f - 12.0).abs() < 1e-12, "got {f}");
2671        // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
2672        let mut g = [0.0_f64; 2];
2673        grad_expr(&p.obj_nonlinear, &[1.0, 2.0], 1.0, &mut g);
2674        assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
2675        assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
2676        // collect_vars reaches into the CSE body and finds {0, 1}.
2677        let mut vs = BTreeSet::new();
2678        collect_vars(&p.obj_nonlinear, &mut vs);
2679        assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
2680    }
2681
2682    /// `min (x0 - 1)^2` with three suffix segments attached: an
2683    /// integer constraint-suffix (target=1, kind=1), an integer var-
2684    /// suffix (target=0, kind=0), and a real var-suffix (target=0,
2685    /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
2686    /// `<idx> <value>` lines.
2687    const WITH_SUFFIXES: &str = "g3 0 1 0
26881 0 1 0 0
26890 1
26900 0
26910 1 0
26920 0 0 1
26930 0 0 0 0
26940 0
26950 0
26960 0 0 0 0
2697O0 0
2698o5
2699o1
2700v0
2701n1
2702n2
2703b
27043
2705S0 1 sens_state_1
27060 7
2707S4 1 sens_state_value_1
27080 4.5
2709";
2710
2711    #[test]
2712    fn parses_var_int_and_var_real_suffixes() {
2713        let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
2714        // Integer var-suffix: dense length 1, slot 0 = 7.
2715        let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
2716        assert_eq!(v.as_slice(), &[7]);
2717        // Real var-suffix: dense length 1, slot 0 = 4.5.
2718        let r = p
2719            .suffixes
2720            .var_real
2721            .get("sens_state_value_1")
2722            .expect("var_real");
2723        assert_eq!(r.len(), 1);
2724        assert!((r[0] - 4.5).abs() < 1e-12);
2725        // Other suffix slots stay empty.
2726        assert!(p.suffixes.con_int.is_empty());
2727        assert!(p.suffixes.con_real.is_empty());
2728    }
2729
2730    /// Two-variable + two-constraint problem with a constraint-level
2731    /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
2732    const WITH_CON_SUFFIX: &str = "g3 0 1 0
27332 2 1 0 0
27340 0
27350 0
27360 2 0
27370 0 0 1
27380 0 0 0 0
27392 0
27400 0
27410 0 0 0 0 0
2742C0
2743n0
2744C1
2745n0
2746O0 0
2747n0
2748r
27494 0.0
27504 0.0
2751b
27523
27533
2754k1
27550
2756J0 2
27570 1
27581 1
2759J1 2
27600 1
27611 -1
2762S1 2 sens_init_constr
27630 1
27641 2
2765";
2766
2767    #[test]
2768    fn parses_con_int_suffix() {
2769        let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
2770        let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
2771        // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
2772        assert_eq!(s.as_slice(), &[1, 2]);
2773    }
2774
2775    #[test]
2776    fn rejects_suffix_with_out_of_range_index() {
2777        let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
2778        let err = parse_nl_text(&bad).expect_err("must reject");
2779        assert!(
2780            err.contains("out of range"),
2781            "expected out-of-range error, got: {err}"
2782        );
2783    }
2784
2785    #[test]
2786    fn tnlp_round_trip_solves() {
2787        let p = parse_nl_text(SIMPLE).expect("parse");
2788        let mut tnlp = NlTnlp::new(p);
2789        let info = tnlp.get_nlp_info().unwrap();
2790        assert_eq!(info.n, 2);
2791        assert_eq!(info.m, 0);
2792        let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
2793        assert!((f0 - 5.0).abs() < 1e-12);
2794        let mut g = [0.0_f64; 2];
2795        tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
2796        // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
2797        assert!((g[0] - (-2.0)).abs() < 1e-12);
2798        assert!((g[1] - (-4.0)).abs() < 1e-12);
2799    }
2800
2801    // ---- Sibling `.col` / `.row` name-file capture --------------------
2802    //
2803    // Names let diagnostics name the offending equation instead of "row 3"
2804    // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
2805    // the read path and the documented fallback-to-empty behavior.
2806
2807    use pounce_nlp::expression_provider::ExpressionProvider;
2808    use std::sync::atomic::{AtomicUsize, Ordering};
2809
2810    /// Unique scratch dir for one test (no `tempfile` dev-dep available).
2811    fn scratch_dir(tag: &str) -> std::path::PathBuf {
2812        static N: AtomicUsize = AtomicUsize::new(0);
2813        let seq = N.fetch_add(1, Ordering::Relaxed);
2814        let dir = std::env::temp_dir().join(format!(
2815            "pounce_nlnames_{}_{}_{}",
2816            std::process::id(),
2817            tag,
2818            seq
2819        ));
2820        std::fs::create_dir_all(&dir).expect("create scratch dir");
2821        dir
2822    }
2823
2824    #[test]
2825    fn read_name_file_reads_in_order() {
2826        let dir = scratch_dir("col_order");
2827        let p = dir.join("m.col");
2828        std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
2829        assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
2830    }
2831
2832    #[test]
2833    fn read_name_file_truncates_extra_lines() {
2834        // `.row` conventionally appends the objective name after the m
2835        // constraint names; `.take(expected)` must drop it so names stay
2836        // 1:1 with `g`.
2837        let dir = scratch_dir("row_obj");
2838        let p = dir.join("m.row");
2839        std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
2840        assert_eq!(
2841            read_name_file(&p, 2),
2842            vec!["mass_balance", "energy_balance"]
2843        );
2844    }
2845
2846    #[test]
2847    fn read_name_file_empty_on_short_or_missing() {
2848        let dir = scratch_dir("short");
2849        let short = dir.join("m.col");
2850        std::fs::write(&short, "only_one\n").unwrap();
2851        // Fewer lines than expected ⇒ empty (never a partial mapping).
2852        assert!(read_name_file(&short, 3).is_empty());
2853        // Missing file ⇒ empty, no error.
2854        assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
2855    }
2856
2857    #[test]
2858    fn read_nl_file_captures_sibling_names() {
2859        // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
2860        // names ride through onto the TNLP's ExpressionProvider.
2861        let dir = scratch_dir("sibling");
2862        let nl = dir.join("m.nl");
2863        std::fs::write(&nl, SIMPLE).unwrap();
2864        std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
2865
2866        let prob = read_nl_file(&nl).expect("parse + name capture");
2867        assert_eq!(prob.var_names, vec!["alpha", "beta"]);
2868        assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
2869
2870        let tnlp = NlTnlp::new(prob);
2871        assert_eq!(tnlp.variable_name(0), Some("alpha"));
2872        assert_eq!(tnlp.variable_name(1), Some("beta"));
2873        assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
2874    }
2875
2876    #[test]
2877    fn read_nl_file_without_names_yields_empty() {
2878        let dir = scratch_dir("noname");
2879        let nl = dir.join("m.nl");
2880        std::fs::write(&nl, SIMPLE).unwrap();
2881        let prob = read_nl_file(&nl).expect("parse");
2882        assert!(prob.var_names.is_empty());
2883        assert!(prob.con_names.is_empty());
2884        let tnlp = NlTnlp::new(prob);
2885        assert_eq!(tnlp.variable_name(0), None);
2886    }
2887
2888    // ---- equation rendering (`print equation`) ----
2889
2890    fn names(v: &[&str]) -> Vec<String> {
2891        v.iter().map(|s| s.to_string()).collect()
2892    }
2893
2894    #[test]
2895    fn render_uses_variable_names_when_present() {
2896        let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
2897        assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
2898        // Falls back to x[i] when names are absent.
2899        assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
2900    }
2901
2902    #[test]
2903    fn render_parenthesizes_by_precedence() {
2904        // (x0 + x1) * x2  must keep the parens around the sum.
2905        let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
2906        let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
2907        assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
2908
2909        // x0 + x1 * x2  needs no parens (mul binds tighter).
2910        let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
2911        let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
2912        assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
2913    }
2914
2915    #[test]
2916    fn render_subtraction_right_assoc_parens() {
2917        // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
2918        let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
2919        let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
2920        assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
2921    }
2922
2923    #[test]
2924    fn render_functions_and_pow() {
2925        let sq = Expr::Binary(
2926            BinOp::Pow,
2927            Box::new(Expr::Var(0)),
2928            Box::new(Expr::Const(2.0)),
2929        );
2930        let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
2931        assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
2932    }
2933
2934    #[test]
2935    fn render_linear_signs_are_tidy() {
2936        // 1*a - 2*b + c  (coef +1 omits the multiplier).
2937        let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
2938        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
2939    }
2940
2941    #[test]
2942    fn render_linear_skips_zero_coefficients() {
2943        // A 0 coefficient (a variable present only in the nonlinear part)
2944        // is dropped, not rendered as `0*x`.
2945        let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
2946        assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
2947        // Leading term zero ⇒ the first emitted term still has no ` + `.
2948        let lin = vec![(0usize, 0.0), (1, 2.0)];
2949        assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
2950    }
2951
2952    #[test]
2953    fn render_sum_folds_negative_terms() {
2954        // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
2955        let sq = |i| {
2956            Expr::Binary(
2957                BinOp::Pow,
2958                Box::new(Expr::Var(i)),
2959                Box::new(Expr::Const(2.0)),
2960            )
2961        };
2962        let neg = |i| {
2963            Expr::Binary(
2964                BinOp::Mul,
2965                Box::new(Expr::Const(-1.0)),
2966                Box::new(Expr::Var(i)),
2967            )
2968        };
2969        let e = Expr::Sum(vec![
2970            sq(0),
2971            neg(1),
2972            Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
2973        ]);
2974        assert_eq!(
2975            render_expr(&e, &names(&["a", "b", "c"]), &[]),
2976            "a^2 - 1*b - c"
2977        );
2978    }
2979
2980    #[test]
2981    fn render_constraint_equation_forms() {
2982        // Build a 2-constraint problem by hand: an equality and a range.
2983        let mut prob = parse_nl_text(SIMPLE).unwrap();
2984        // Overwrite to a known small shape: 1 var, 2 cons.
2985        prob.n = 2;
2986        prob.m = 2;
2987        prob.var_names = names(&["mass_in", "mass_out"]);
2988        prob.con_names = names(&["balance", "window"]);
2989        prob.con_linear = vec![
2990            vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
2991            vec![(0, 1.0)],            // mass_in
2992        ];
2993        prob.con_nonlinear = vec![Expr::Const(0.0), Expr::Const(0.0)];
2994        prob.g_l = vec![0.0, 0.0];
2995        prob.g_u = vec![0.0, 500.0];
2996
2997        assert_eq!(
2998            render_constraint_equation(&prob, 0),
2999            "mass_in - mass_out = 0"
3000        );
3001        assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
3002
3003        let all = render_all_constraint_equations(&prob);
3004        assert_eq!(all.len(), 2);
3005        assert_eq!(all[1], "0 <= mass_in <= 500");
3006    }
3007
3008    #[test]
3009    fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
3010        let mut prob = parse_nl_text(SIMPLE).unwrap();
3011        prob.n = 3;
3012        prob.m = 2;
3013        // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
3014        // Row 1: linear in x2 only → support {2}.
3015        prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
3016        prob.con_nonlinear = vec![
3017            Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(2))),
3018            Expr::Const(0.0),
3019        ];
3020        prob.g_l = vec![0.0, 0.0];
3021        prob.g_u = vec![0.0, 0.0];
3022
3023        let (irow, jcol) = constraint_jacobian_sparsity(&prob);
3024        // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
3025        assert_eq!(irow, vec![0, 0, 0, 1]);
3026        assert_eq!(jcol, vec![0, 1, 2, 2]);
3027    }
3028}