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