pounce_nl/nl_reader.rs
1//! Minimal AMPL `.nl` ASCII-format reader.
2//!
3//! Implements the `g`-header text dialect for problems whose constraint
4//! and objective expressions are restricted to a polynomial-friendly
5//! subset of opcodes. This is **not** a full `.nl` reader — it is the
6//! smallest piece that lets `pounce --nl-file foo.nl` solve a real
7//! AMPL-emitted unconstrained problem.
8//!
9//! Supported:
10//! * Text header (`g…`).
11//! * Constraint and objective expression segments using opcodes
12//! `o0` (add), `o1` (sub), `o2` (mul), `o3` (div), `o5` (pow),
13//! `o16` (unary minus), `o39` (sqrt), `o42` (log10), `o43` (log),
14//! `o44` (exp), `o15` (abs), `o41` (sin), `o46` (cos), `o38` (tan),
15//! `o49` (atan), `o53` (acos), plus
16//! `n<num>` constants and `v<idx>` variables.
17//! * Linear-Jacobian (`J`) and linear-objective (`G`) segments.
18//! * Variable bounds (`b`) and constraint bounds (`r`).
19//! * Optional initial primal (`x`) segment and initial dual (`d`)
20//! segment. Both are parsed (into `x0` / `lambda0`) and returned by
21//! `get_starting_point`; the duals feed a `warm_start_init_point` solve.
22//! * Multiple objectives (we use only the first; per AMPL convention).
23//!
24//! Not supported (will return an error explaining what's missing):
25//! * Network / piecewise-linear constructs.
26//! * Complementarity rows.
27//! * Binary-format `.nl` files (`b…` header).
28//!
29//! References:
30//! * <https://ampl.com/REFS/hooking2.pdf> — "Hooking Your Solver to
31//! AMPL" (David M. Gay), the canonical `.nl` spec.
32//! * `ref/Ipopt/test/mytoy.nl` — annotated example used for the unit
33//! tests in this module.
34
35use crate::nl_quadratic::{
36 FactoredQuadratic, Quad2, QuadForm, QuadHessian, is_expanded_quadratic, is_trivially_zero,
37 quad_form_readout, recognize_expr, recognize_factored_quadratic,
38};
39use crate::nl_tape::{HybridTape, Tape, hybrid_supported};
40use pounce_common::types::{Index, Number, lower_bound_present, upper_bound_present};
41use pounce_nlp::constant_derivatives::{DerivativeProof, DerivativeProofs};
42use pounce_nlp::quadratic::{QuadraticStructure, SquareTerm};
43use pounce_nlp::tnlp::{
44 BoundsInfo, IDX_NAMES, IndexStyle, IpoptCq, IpoptData, Linearity, MetaData, NlpInfo,
45 ScalingRequest, Solution, SparsityRequest, StartingPoint, TNLP,
46};
47use std::cell::RefCell;
48use std::collections::{BTreeMap, BTreeSet};
49use std::path::Path;
50use std::rc::Rc;
51use std::sync::Arc;
52
53#[derive(Debug, Clone)]
54pub enum Expr {
55 /// Numeric constant.
56 Const(Number),
57 /// Variable reference (0-based index into `x`).
58 Var(usize),
59 /// Binary op: `args = [lhs, rhs]`.
60 Binary(BinOp, Box<Expr>, Box<Expr>),
61 /// Unary op.
62 Unary(UnaryOp, Box<Expr>),
63 /// n-ary sum (opcode `o54` — variadic; we may emit it from `o0`
64 /// folding optimization, but the parser treats `o0` as binary).
65 Sum(Vec<Expr>),
66 /// Reference to a common subexpression (`.nl` `V` segment). The
67 /// payload is a shared body; many references to the same CSE share
68 /// one `Arc`, so the parsed problem is a DAG. Walking through `Cse`
69 /// is mathematically equivalent to inlining the body at each
70 /// occurrence (every reference is an independent occurrence in the
71 /// chain rule), so eval/grad/collect_vars just recurse into the
72 /// inner `Expr`. The pointer is atomically refcounted (`Arc`, not
73 /// `Rc`) so a parsed problem — and the `NlTnlp` built from it —
74 /// is `Send` and can move to a rayon worker for batched solving
75 /// (pounce#126); sharing is still read-only after parse.
76 Cse(Arc<Expr>),
77 /// AMPL imported (external) function call. `id` matches an entry in
78 /// `NlProblem.imported_funcs`; resolution to a live shared library
79 /// happens when the tape is built (see `nl_external::ExternalResolver`).
80 Funcall { id: usize, args: Vec<FuncallArg> },
81 /// Relational comparison (`o22`/`o23`/`o24`/`o28`/`o29`/`o30`).
82 /// Evaluates to `1.0` when the comparison holds, else `0.0`. The
83 /// result is piecewise-constant, so it has zero derivative
84 /// everywhere (the kink at equality is ignored — standard
85 /// subgradient-free treatment, matching ASL).
86 Compare(CmpOp, Box<Expr>, Box<Expr>),
87 /// Logical AND (`o21`). `1.0` iff both operands are nonzero.
88 /// Zero derivative (piecewise constant).
89 And(Box<Expr>, Box<Expr>),
90 /// Logical OR (`o20`). `1.0` iff either operand is nonzero.
91 /// Zero derivative (piecewise constant).
92 Or(Box<Expr>, Box<Expr>),
93 /// Logical NOT (`o34`). `1.0` iff the operand is zero.
94 /// Zero derivative (piecewise constant).
95 Not(Box<Expr>),
96 /// `if-then-else` (`o35` OPIFnl). Evaluates `cond`; when it is
97 /// nonzero the value and all derivatives flow through `then_`,
98 /// otherwise through `else_`. The branch switch is a non-smooth
99 /// event the derivative ignores (it differentiates only the
100 /// active branch), exactly as ASL/IPOPT does for `if`.
101 Cond {
102 cond: Box<Expr>,
103 then_: Box<Expr>,
104 else_: Box<Expr>,
105 },
106 /// n-ary minimum (`o11` MINLIST). Value is the smallest operand.
107 /// Piecewise linear: the derivative flows through whichever operand
108 /// is currently smallest (a subgradient; ties resolve to the first
109 /// such operand), and the second derivative is identically zero —
110 /// the standard AD treatment for min/max, matching ASL/IPOPT.
111 MinList(Vec<Expr>),
112 /// n-ary maximum (`o12` MAXLIST). Value is the largest operand;
113 /// derivative routing mirrors [`Expr::MinList`].
114 MaxList(Vec<Expr>),
115}
116
117/// Relational operator carried by [`Expr::Compare`]. The variants map
118/// 1:1 onto AMPL opcodes `o22 LT`, `o23 LE`, `o24 EQ`, `o28 GE`,
119/// `o29 GT`, `o30 NE`.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CmpOp {
122 Lt,
123 Le,
124 Eq,
125 Ge,
126 Gt,
127 Ne,
128}
129
130/// One positional argument to an AMPL imported function call. AMPL splits
131/// arguments into reals (carried by `ra[]`) and strings (carried by `sa[]`);
132/// `FuncallArg` mirrors that split. Real args are arbitrary expressions.
133#[derive(Debug, Clone)]
134pub enum FuncallArg {
135 Real(Expr),
136 Str(String),
137}
138
139/// An AMPL imported (external) function declaration from a top-level
140/// `F<id> <type> <nargs> <name>` segment.
141#[derive(Debug, Clone)]
142pub struct ImportedFunc {
143 pub id: usize,
144 /// 0 = real-valued, 1 = string-args (per AMPL's funcadd ABI).
145 pub kind: usize,
146 /// Declared arg count. >=0 exact arity; <=-1 means at least `-(nargs+1)`.
147 pub nargs: i64,
148 pub name: String,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum BinOp {
153 Add,
154 Sub,
155 Mul,
156 Div,
157 Pow,
158 /// Two-argument arctangent `atan2(a, b)` with operands `(y, x)`.
159 Atan2,
160 /// `a·ln(a/b)` — GAMS `centropy`. No `.nl` opcode; in-memory `Expr` only.
161 ///
162 /// Fused for the same reason as [`UnaryOp::XLogX`], plus one of its own:
163 /// `∂²/∂b²` is `a/b²`, and `b²` overflows for `|b| > 1.3e154` while
164 /// `a/b²` itself stays comfortably in range. The fused rule evaluates it
165 /// as `q/b` with `q = a/b` and never squares anything.
166 CEntropy,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum UnaryOp {
171 Neg,
172 Sqrt,
173 Log,
174 Exp,
175 Abs,
176 Sin,
177 Cos,
178 Log10,
179 Tan,
180 Atan,
181 Acos,
182 Sinh,
183 Cosh,
184 Tanh,
185 Asin,
186 Acosh,
187 Asinh,
188 Atanh,
189 /// Gauss error function. No `.nl` opcode maps here — AMPL has no `erf`,
190 /// so the parser never emits it — but the in-memory builder (issue #469)
191 /// does, which is the whole point: a frontend that constructs an `Expr`
192 /// directly is not limited to what `.nl` can spell.
193 Erf,
194 /// `a·ln(a)` — GAMS `entropy`. Like [`UnaryOp::Erf`], no `.nl` opcode maps
195 /// here; it is reachable only from an in-memory `Expr`.
196 ///
197 /// Fused rather than lowered to `Mul(a, Log(a))` because the chain rule
198 /// *cannot* produce its second derivative. `(a·ln a)'' = 1/a` is finite
199 /// wherever `a > 0` — at `a = 1e-299` it is `1e299` — but every
200 /// decomposition routes through `ln''(a) = -1/a² = -1e598`, which exceeds
201 /// `f64::MAX`. A composite that is in range, built from a factor that is
202 /// not, is unreachable by any chain rule however carefully written, so the
203 /// fusion is a correctness requirement rather than an optimization.
204 XLogX,
205}
206
207/// The `.nl` header's nonlinearity census — lines 3 and 5 of Gay's header
208/// table (*Hooking Your Solver to AMPL*, §D and Table 1).
209///
210/// AMPL has already done this analysis when it writes the file, so these are
211/// facts about the model that cost nothing to keep and would otherwise have
212/// to be recovered by walking every expression tree in it.
213///
214/// ```text
215/// 55 1 # nonlinear constraints, objectives -> nl_cons, nl_objs
216/// 100 110 100 # nonlinear vars in constraints, objectives, both
217/// ```
218///
219/// Two properties of the format make these usable rather than merely
220/// informative, and both are asserted against the fixture corpus in
221/// `crates/pounce-cli/tests/nl_header_counts.rs`:
222///
223/// 1. **Nonlinear rows come first.** Constraints `0..nl_cons` are the ones
224/// with a nonlinear body; objectives `0..nl_objs` likewise.
225/// 2. **Nonlinear variables come first.** The `.nl` variable order is
226/// "nonlinear in both, then constraints-only, then objectives-only, then
227/// everything linear", so the variables that appear nonlinearly occupy a
228/// prefix of length [`NlCounts::nonlinear_vars`].
229///
230/// The counts are what the *writer* asserted, which is not always what the
231/// parsed trees say: `parse_nl_text` folds a variable-free `C` body into the
232/// row bounds (`gh #492`), so a row counted in `nl_cons` can arrive here with
233/// a nonlinear part of `Const(0.0)`. The discrepancy is one-directional —
234/// the header only ever over-states nonlinearity relative to the trees — and
235/// every consumer below is written to be sound under exactly that direction.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct NlCounts {
238 /// `nlc`: constraints with a nonlinear body.
239 pub nl_cons: usize,
240 /// `nlo`: objectives with a nonlinear body.
241 pub nl_objs: usize,
242 /// `nlvc`: variables appearing nonlinearly in constraints. **Includes**
243 /// the [`Self::nl_vars_both`] variables that are also nonlinear in an
244 /// objective.
245 pub nl_vars_cons: usize,
246 /// `nlvo`: variables appearing nonlinearly in objectives. Also includes
247 /// [`Self::nl_vars_both`].
248 pub nl_vars_objs: usize,
249 /// `nlvb`: variables appearing nonlinearly in *both* a constraint and an
250 /// objective. Counted a second and third time in the two fields above,
251 /// which is why the total is an inclusion–exclusion and not a sum.
252 pub nl_vars_both: usize,
253}
254
255impl NlCounts {
256 /// Number of distinct variables that appear nonlinearly anywhere:
257 /// `nlvc + nlvo − nlvb`, because `nlvb` is double-counted by the other
258 /// two. Saturating, so a malformed header cannot underflow.
259 ///
260 /// This is *not* `max(nlvc, nlvo)`: for `min x₀² s.t. x₁² ≤ 1` the counts
261 /// are `nlvc = nlvo = 1`, `nlvb = 0` and there are two nonlinear
262 /// variables, not one.
263 pub fn nonlinear_vars(&self) -> usize {
264 self.nl_vars_cons
265 .saturating_add(self.nl_vars_objs)
266 .saturating_sub(self.nl_vars_both)
267 }
268}
269
270/// The nonlinear body of the objective, or of one constraint row, as the
271/// parser left it.
272///
273/// Before gh #588 Q5 this was always an [`Expr`], and for most bodies it
274/// still is. The second variant exists because the `Expr` DAG is what sets
275/// peak RSS on a quadratic model — 2.32 M nodes for a ten-row
276/// `qcqp500-3c` — and every consumer of a *recognized* body wants the
277/// degree-≤2 coefficients rather than the tree they would have to walk to
278/// recover them. So the parser recognizes those bodies from the token
279/// stream and never builds the tree at all.
280///
281/// The distinction is deliberately impossible to ignore. Nine consumers
282/// read these bodies (§5.3 of `dev-notes/quadratic-structure-exploitation.md`
283/// enumerates them) and several would read a *missing* tree as "this row is
284/// linear" — a silent wrong answer. An enum makes every one of them a
285/// compile error until it says which reading it wants.
286#[derive(Debug, Clone)]
287pub enum NlBody {
288 /// The expression tree.
289 Tree(Expr),
290 /// A degree-2 form recognized while the token stream was consumed. The
291 /// tree was never built; [`NlProblem::con_expr`] rebuilds it on demand,
292 /// byte for byte, by re-parsing the same bytes with the same parser.
293 Quad(Box<QuadBody>),
294}
295
296/// A body the parser recognized as an already-expanded quadratic.
297#[derive(Debug, Clone)]
298pub struct QuadBody {
299 /// The recognized form. Bit-for-bit what
300 /// [`crate::nl_quadratic::recognize_expr`] returns for the tree these
301 /// same bytes parse to — asserted directly, over the whole corpus, by
302 /// `pounce-cli/tests/quad_parse_differential.rs`.
303 pub form: Quad2,
304 /// Every variable the body's token stream mentions, ascending and
305 /// deduplicated — exactly the set [`collect_vars`] reports for the tree,
306 /// **including** variables whose coefficient cancelled to zero (which
307 /// `form` necessarily drops). Structural consumers want this one; the
308 /// linearity contract is over-stating-is-safe, and a support taken from
309 /// `form` would under-state.
310 pub vars: Vec<u32>,
311 /// Byte range of this body's token stream inside [`NlProblem::src`].
312 pub src: std::ops::Range<usize>,
313 /// Nesting depth of the tree these tokens would have built, on the same
314 /// convention as a leaf counting 1.
315 ///
316 /// Recorded because the streaming recognizer is iterative and the tree
317 /// parser is not: a body deep enough to overflow the parser's stack now
318 /// *loads*, and the depth guard that used to be enforced implicitly by
319 /// the parse failing has to be enforced by something. `pounce-py`'s
320 /// `checked_depth` reads it (pounce #472). Rebuilding such a body with
321 /// [`NlProblem::con_expr`] would still recurse, which is the same
322 /// ceiling Q3 recorded and Q5 does not lift.
323 pub depth: u32,
324}
325
326impl NlBody {
327 /// The identity zero — "this row has no nonlinear part". A recognized
328 /// body is degree 2 by construction, so it is never trivially zero;
329 /// the question still has to be asked through here rather than by
330 /// matching on a tree that may not exist.
331 pub fn is_trivially_zero(&self) -> bool {
332 match self {
333 NlBody::Tree(e) => matches!(e, Expr::Const(c) if *c == 0.0),
334 NlBody::Quad(_) => false,
335 }
336 }
337
338 /// The recognized degree-2 form, when the parser produced one.
339 pub fn quad(&self) -> Option<&Quad2> {
340 match self {
341 NlBody::Tree(_) => None,
342 NlBody::Quad(q) => Some(&q.form),
343 }
344 }
345
346 /// The tree, when there is one resident. `None` for a recognized body —
347 /// use [`NlProblem::con_expr`] / [`NlProblem::obj_expr`] to rebuild it.
348 pub fn tree(&self) -> Option<&Expr> {
349 match self {
350 NlBody::Tree(e) => Some(e),
351 NlBody::Quad(_) => None,
352 }
353 }
354
355 /// This body as a degree-≤2 Hessian, or `None` if it is not provably
356 /// quadratic — [`crate::nl_quadratic::analyze_quadratic`] for a tree,
357 /// and the form the parser already proved for a recognized body. The
358 /// two answers are the same by construction and asserted to be so bit
359 /// for bit; this is the accessor that keeps the corpus from being
360 /// re-recognized once per consumer.
361 ///
362 /// `None` also when a term was dropped getting to the form — see
363 /// [`Self::analyze_quadratic_full`].
364 pub fn analyze_quadratic(&self) -> Option<QuadHessian> {
365 self.analyze_quadratic_full().map(|(h, _, _)| h)
366 }
367
368 /// [`Self::analyze_quadratic`] with the linear and constant parts.
369 ///
370 /// ## A form that dropped a term is not this body
371 ///
372 /// Everything downstream of here *reads coefficients out*: the
373 /// classifier decides a problem class from the Hessian, and
374 /// `qp_extract` builds `P`, `c`, `A` and `G` from all three parts. So
375 /// this accessor owes its callers a form that is the whole body, and
376 /// a recognized form is only that when nothing was dropped reaching
377 /// it. `2⁵³·x₀² + x₀² − 2⁵³·x₀²` folds to `x₀²` and stores nothing;
378 /// `(10⁻²⁰⁰·x₀)·(10⁻²⁰⁰·x₀)` underflows the same way (gh #683).
379 ///
380 /// Handing that form out is what routed the reproduction in gh #685
381 /// to the **LP** fast path: with the row's only quadratic term gone
382 /// the classifier saw a linear row, `qp_extract` folded an empty
383 /// linear part into `G`, and the constraint left the model
384 /// altogether — `min −x₀` subject to a vanished row walks `x₀` to its
385 /// `10⁶` bound and reports `Optimal`. A wrong answer, on the default
386 /// route, with no option set (gh #685 part 2).
387 ///
388 /// The gate is [`Quad2::lost_terms`] and not an emptiness test, for
389 /// the reason spelled out on [`Self::admitted_quad_form`]: partial
390 /// cancellation leaves a non-empty map that is still short a term.
391 /// Refusing costs reach only — the row falls back to the AD tape,
392 /// and the model to the NLP path, which solves it soundly.
393 ///
394 /// `lost_terms` is the *inexact fold* and not the drop it leads to
395 /// (gh #687), so the reach given up here is only the reach that has
396 /// to be. `x − x` cancels exactly — nothing was lost, the form is
397 /// the body, and it is still handed out; `2⁵³·x + x − 2⁵³·x` loses
398 /// the `x` at `fl(2⁵³ + 1)`, and that is what this refuses.
399 ///
400 /// Use [`Self::quad_terms_dropped`] to tell the two `None`s apart.
401 pub fn analyze_quadratic_full(&self) -> Option<QuadForm> {
402 match self {
403 NlBody::Tree(e) => {
404 let form = recognize_expr(e)?;
405 (!form.lost_terms()).then(|| quad_form_readout(&form))
406 }
407 NlBody::Quad(q) => (!q.form.lost_terms()).then(|| quad_form_readout(&q.form)),
408 }
409 }
410
411 /// Whether the recognizer reached a degree-≤2 form for this body but
412 /// lost at least one term getting there — the case
413 /// [`Self::analyze_quadratic_full`] refuses.
414 ///
415 /// `false` both for a body that recognized cleanly and for one that
416 /// did not recognize at all, so this separates the two reasons that
417 /// accessor answers `None`; it is not a nonlinearity test. Meant for
418 /// the *refusal* path (the classifier naming its reason), not the hot
419 /// one: on a tree it re-runs the recognizer.
420 pub fn quad_terms_dropped(&self) -> bool {
421 match self {
422 NlBody::Tree(e) => recognize_expr(e).is_some_and(|f| f.lost_terms()),
423 NlBody::Quad(q) => q.form.lost_terms(),
424 }
425 }
426
427 /// The form the constant-structure evaluator is allowed to use — i.e.
428 /// [`Self::analyze_quadratic_full`] behind the *exactness* gate.
429 ///
430 /// A recognized body has already passed that gate: the parser admits
431 /// only a flat sum of monomials, which is the same rule
432 /// [`crate::nl_quadratic::is_expanded_quadratic`] applies to a tree.
433 /// Reading a factored form out of stored coefficients cancels — five
434 /// digits on `(x − 500000)²` — so the gate is on both arms or on
435 /// neither (gh #588, Q4).
436 ///
437 /// A body this refuses for that reason is not out of reach, only out of
438 /// *this* representation: [`Self::admitted_factored_form`] serves it by
439 /// keeping the squares factored (gh #673), and only a body neither can
440 /// express keeps its tape.
441 ///
442 /// ## The second gate: a term that was *lost* is a term that is missing
443 ///
444 /// `is_expanded_quadratic` is a gate on the *shape* the coefficients
445 /// were derived from. It says nothing about whether the derivation kept
446 /// them. A flat sum of monomials passes it and still folds to a form
447 /// with an entry missing, because the fold is floating-point addition:
448 /// `2⁵³·x₀² + x₀² − 2⁵³·x₀²` is `x₀²` and stores nothing, and
449 /// `(10⁻²⁰⁰·x₀)·(10⁻²⁰⁰·x₀)` underflows the same way (gh #683).
450 ///
451 /// Evaluating **that** form is not a five-digit cancellation, it is a
452 /// missing term. At `x₀ = 3` the row reads `0` where its own tape reads
453 /// `16`, and `∂g/∂x` reads `[0, 0]` where the tape reads `[8, 0]` — so
454 /// the `≤` the row sits under stops constraining anything at all. In the
455 /// reproduction (`issue_685_cancelled_quadratic_evaluation`) the solve
456 /// then walks the objective variable to its `-10⁶` floor and reports
457 /// `Optimal`, where the same bytes down the tape stop at `-0.281`. On
458 /// the default route, with no option set. So the form is admitted only
459 /// when [`Quad2::lost_terms`] is clear (gh #685 part 1).
460 ///
461 /// It has to be that flag and not an emptiness test. Partial
462 /// cancellation is the same defect wearing a different face:
463 /// `2⁵³·x₀² + x₀² − 2⁵³·x₀² + x₁²` keeps `x₁²`, so the map is not empty
464 /// and [`Self::provably_affine`] answers `Some(false)` quite correctly —
465 /// while the read-out is still short an entire `x₀²`. A gate that looked
466 /// at emptiness would pass this and stay wrong.
467 ///
468 /// And it is the *loss*, not the drop. `x₀² − x₀²` folds through
469 /// `fl(1) + fl(−1) = 0` with nothing rounded away, so its read-out is
470 /// the whole body and it keeps this fast path; gating on the drop
471 /// refused it alongside the absorbing row above, for arithmetic that
472 /// lost nothing (gh #687).
473 ///
474 /// The cost is reach, not correctness: a row that lost a term goes back
475 /// to the AD tape, which is where it was before Q4.
476 pub fn admitted_quad_form(&self) -> Option<QuadForm> {
477 match self {
478 NlBody::Tree(e) => {
479 if is_trivially_zero(e) || !is_expanded_quadratic(e) {
480 return None;
481 }
482 let form = recognize_expr(e)?;
483 (!form.lost_terms()).then(|| quad_form_readout(&form))
484 }
485 NlBody::Quad(q) => (!q.form.lost_terms()).then(|| quad_form_readout(&q.form)),
486 }
487 }
488
489 /// The **factored** form the constant-structure evaluator may use when
490 /// [`Self::admitted_quad_form`] refuses (gh #673).
491 ///
492 /// That accessor's gate is `is_expanded_quadratic`, and what it refuses
493 /// is a body whose read-out would be an algebraic *expansion* of what
494 /// the writer wrote — `(x − 500000)²` read back as
495 /// `x² − 10⁶x + 2.5·10¹¹`, five digits gone. The refusal was never
496 /// about the body being unsuitable for constant-structure evaluation;
497 /// it was about the *representation*. So a body written as a sum of
498 /// squared residuals — which is every least-squares model, and 41 of
499 /// `airport.nl`'s 42 rows — is served here instead, by keeping the
500 /// writer's own grouping and squaring it at evaluation time exactly as
501 /// the tape does. See
502 /// [`recognize_factored_quadratic`](crate::nl_quadratic::recognize_factored_quadratic)
503 /// for what is admitted and why it costs no accuracy.
504 ///
505 /// Answers `None` for a body the parser recognized: a
506 /// [`NlBody::Quad`] is an already-flat sum of monomials by
507 /// construction, so it has no factoring left to keep and
508 /// [`Self::admitted_quad_form`] has already served it.
509 ///
510 /// ## This arm answers for the bodies refused on *shape*, and only those
511 ///
512 /// [`Self::admitted_quad_form`] says `None` for two different reasons,
513 /// and only one of them is this arm's. A body refused because its shape
514 /// is factored is what this serves. A body refused because a term went
515 /// **missing** in the fold ([`Quad2::lost_terms`], gh #685) keeps its
516 /// tape, and the explicit `is_expanded_quadratic` test here is what
517 /// keeps it there: those bodies are flat sums of monomials, so the
518 /// square-shaped ones among them — `2⁵³x₀² + x₀² − 2⁵³x₀²` is three —
519 /// would otherwise be admitted here by the back door.
520 ///
521 /// What breaks when they are is worth stating, because it is *not* that
522 /// the fast path becomes less accurate. Measured with this test
523 /// removed: the row whose tape answers `16.0` at `x₀ = 3` is answered
524 /// `9.0` by the factored arm — and `9.0` is the mathematically right
525 /// value of `x₀²`, which the compensated outer sum (gh #702) recovers
526 /// and the tape's naive fold does not. End to end the reproduction
527 /// moves from `−1.812` to `−2.236`, which is `−√5`, the true optimum of
528 /// the model those bytes describe.
529 ///
530 /// It is still a defect, for the reason this file's own doc comment
531 /// gives: the tape is the reference because it is what the row means
532 /// *to this solver*, not because it is exact. Two routes over the same
533 /// bytes that answer `9` and `16` are a `POUNCE_DBG_NO_QUAD`-shaped
534 /// divergence whichever one is closer to the algebra. (The Hessian
535 /// **pattern** diverges too — `Σ 2wₖbₖbₖᵀ` folds that row's `(0, 0)` to
536 /// exactly `0.0`, `2⁵⁴ + 2` tying back to `2⁵⁴`, and a zero entry is
537 /// not stored where the tape declares one: `nnz_h` 2 → 1.)
538 ///
539 /// Pinned by
540 /// `a_row_that_dropped_a_term_is_not_admitted_as_a_factored_form_either`.
541 ///
542 /// Callers must still try [`Self::admitted_quad_form`] first: both can
543 /// answer for the same body (a bare `x²` is a monomial and a square),
544 /// and the expanded arm is the cheaper evaluation — a matvec over a
545 /// merged row rather than one squaring per term.
546 pub fn admitted_factored_form(&self) -> Option<FactoredQuadratic> {
547 match self {
548 NlBody::Tree(e) => {
549 if is_trivially_zero(e) || is_expanded_quadratic(e) {
550 return None;
551 }
552 recognize_factored_quadratic(e)
553 }
554 NlBody::Quad(_) => None,
555 }
556 }
557
558 /// Whether this body is provably **affine** — degree ≤ 1 — as a
559 /// three-valued answer: `Some(true)` proved affine, `Some(false)`
560 /// proved to have a nonzero second derivative, `None` no proof
561 /// either way.
562 ///
563 /// This is the degree question *without* the exactness gate
564 /// [`Self::admitted_quad_form`] applies, and the difference is the
565 /// point (gh #588, Q6). That gate exists because reading a value out
566 /// of stored coefficients cancels for a factored form; nothing is
567 /// read out here. The answer is used to decide whether a derivative
568 /// may be **reused** across iterates — a question about the
569 /// *degree* of the body, which a factored `(x − a)²` answers just as
570 /// well as an expanded one.
571 ///
572 /// `None` is not evidence of nonlinearity: the recognizer refuses
573 /// `2·(x + 1)`, which is affine. Consumers must treat it as "not
574 /// established".
575 ///
576 /// ## What the exactness argument above still does not buy
577 ///
578 /// It is true that nothing is *evaluated* from the coefficients here.
579 /// What the argument missed is that the **degree answer is itself
580 /// computed by the coefficient arithmetic**: the recognizer sums a
581 /// row's quadratic coefficients in floating point and drops the ones
582 /// that reach exactly zero, so `2⁵³·x² + x² − 2⁵³·x²` — and
583 /// `(10⁻²⁰⁰·x)·(10⁻²⁰⁰·x)`, by underflow — folded to an empty
584 /// quadratic map and were reported *proved affine*. Q6's consumer then
585 /// froze those rows' Jacobians for the whole solve (gh #683).
586 ///
587 /// So an empty quadratic map is a proof of degree ≤ 1 only when no term
588 /// went missing getting there, which is what
589 /// [`Quad2::lost_terms`](crate::nl_quadratic::Quad2::lost_terms)
590 /// records. When one did, this answers `None` — the state the contract
591 /// already reserved for "not established", which is why the fix needs
592 /// nothing of its consumer.
593 ///
594 /// A term that cancelled **exactly** did not go missing, and gh #687 is
595 /// where that stopped costing a proof: `x₀² − x₀²` is degree 0 by an
596 /// add that rounded nothing, its tape holds `∂g/∂x` at zero for every
597 /// `x`, and answering `None` for it gave up a whole solve of frozen
598 /// Jacobian to be safe from arithmetic that never happened.
599 ///
600 /// Deliberately answers from the term maps rather than from
601 /// [`Self::analyze_quadratic_full`]'s triplets: on `qssp180` that is
602 /// 65 341 recognized rows, and materializing a `QuadHessian` per row
603 /// to ask whether it is empty is the allocation-per-object cost Q3
604 /// removed from the recognizer in the first place.
605 pub fn provably_affine(&self) -> Option<bool> {
606 match self {
607 NlBody::Tree(e) => {
608 if is_trivially_zero(e) {
609 return Some(true);
610 }
611 affine_from_form(&recognize_expr(e)?)
612 }
613 NlBody::Quad(q) => affine_from_form(&q.form),
614 }
615 }
616
617 /// Add this body's structural variable support to `out`.
618 pub fn collect_vars(&self, out: &mut BTreeSet<usize>) {
619 match self {
620 NlBody::Tree(e) => collect_vars(e, out),
621 NlBody::Quad(q) => out.extend(q.vars.iter().map(|&v| v as usize)),
622 }
623 }
624}
625
626/// Give one body a constant-structure form, if either representation will
627/// take it, and return its id.
628///
629/// The order is the contract [`NlBody::admitted_factored_form`] states:
630/// the expanded read-out first, because it is the cheaper evaluation and
631/// because it is what the `qcqp*` family — the target of gh #588's Q4 —
632/// arrives as; the factored one only for the bodies that gate refuses
633/// (gh #673). A body neither admits keeps its AD tape, which is where every
634/// body was before Q4.
635fn push_body_form(quad: &mut QuadraticStructure, body: &NlBody) -> Option<u32> {
636 if let Some((h, lin, c)) = body.admitted_quad_form() {
637 return Some(quad.push_form(&h, &lin, c));
638 }
639 let fq = body.admitted_factored_form()?;
640 let terms: Vec<SquareTerm<'_>> = fq
641 .squares
642 .iter()
643 .map(|t| SquareTerm {
644 weight: t.weight,
645 coefs: &t.coefs,
646 constant: t.constant,
647 })
648 .collect();
649 // `None` here is the gh #685 gate on this arm: the body is factored,
650 // but assembling `Σ 2wₖbₖbₖᵀ` dropped an entry the tape will declare.
651 // Falls through to the tape, like any other refusal.
652 quad.push_factored_form(&terms, &fq.linear, fq.constant)
653}
654
655/// The degree read-out [`NlBody::provably_affine`] makes of a recognized
656/// form, in one place so both arms answer it the same way.
657///
658/// A stored quadratic coefficient is a witness that the body is degree 2.
659/// An *absent* one is only a witness of the opposite when nothing went
660/// missing on the way — see [`Quad2::lost_terms`], gh #683 and gh #687.
661fn affine_from_form(q: &Quad2) -> Option<bool> {
662 if q.quadratic().is_empty() {
663 (!q.lost_terms()).then_some(true)
664 } else {
665 Some(false)
666 }
667}
668
669impl From<Expr> for NlBody {
670 fn from(e: Expr) -> Self {
671 NlBody::Tree(e)
672 }
673}
674
675/// Parsed `.nl` problem in the form needed by `NlTnlp`.
676#[derive(Debug, Clone)]
677pub struct NlProblem {
678 pub n: usize,
679 pub m: usize,
680 pub num_obj: usize,
681 pub minimize: bool,
682 pub obj_nonlinear: NlBody,
683 pub obj_linear: Vec<(usize, Number)>,
684 pub obj_constant: Number,
685 /// Per-constraint nonlinear part (length m).
686 pub con_nonlinear: Vec<NlBody>,
687 /// Per-constraint linear part (length m), each a list of (var, coef).
688 pub con_linear: Vec<Vec<(usize, Number)>>,
689 pub x_l: Vec<Number>,
690 pub x_u: Vec<Number>,
691 pub g_l: Vec<Number>,
692 pub g_u: Vec<Number>,
693 pub x0: Vec<Number>,
694 /// Initial constraint multipliers from the `d` segment, in the engine's
695 /// sign (`L = f + lambda' g`, `f` negated for a maximize model):
696 /// `-sigma * dual` for the AMPL duals the file carries, the inverse of
697 /// the CLI's `.sol` writer (gh#1033).
698 pub lambda0: Vec<Number>,
699 /// AMPL suffix dictionaries. Variable / constraint / objective
700 /// suffixes are stored as dense vectors (length n / m / num_obj)
701 /// with the sparse `.nl` `S`-segment entries scattered in, default
702 /// zero. The integer / real split matches the `S`-segment header's
703 /// kind bit (`0x4` ⇒ real, else integer). See
704 /// <https://ampl.com/REFS/hooking2.pdf> §6 and the upstream `.nl`
705 /// reader in `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`.
706 pub suffixes: NlSuffixes,
707 /// The model's own AMPL option words, taken verbatim from `.nl`
708 /// header line 0 (`g<count> <opt0> <opt1> ...`). A solver echoes
709 /// these back in the `.sol` `Options` block rather than interpreting
710 /// them — see [`crate::sol_writer::format_sol_with_options`]. Empty
711 /// for problems not built from a `.nl` file.
712 pub ampl_options: Vec<i64>,
713 /// The header's nonlinearity census, when the problem came from a `.nl`
714 /// file whose header parsed cleanly. `None` for a model built in memory
715 /// ([`NlProblem::from_expressions`]) — there is no header to read, and
716 /// inventing one would let a consumer trust a count nobody computed.
717 pub nl_counts: Option<NlCounts>,
718 /// Header line 7's discrete-variable census: declared binary
719 /// and general-integer variables (gh#987). `None` when there was no header
720 /// or it did not parse. pounce solves the continuous relaxation, so a
721 /// nonzero count means the answer is a bound, not a MIP solution.
722 pub n_discrete: Option<DiscreteCensus>,
723 /// AMPL imported (external) functions declared via top-level `F` segments.
724 /// Empty unless the `.nl` file calls compiled-C user functions (typically
725 /// emitted by IDAES property packages — see issue #49).
726 pub imported_funcs: Vec<ImportedFunc>,
727 /// Variable names from the sibling `.col` file, index-aligned to `x`
728 /// (one name per line, column order). Empty when no `.col` file was
729 /// found — AMPL only emits it under `option auxfiles rc;`.
730 ///
731 /// Carrying names lets diagnostics report `flow_balance` / `T_reactor`
732 /// instead of `c[3]` / `x[132]`. Lee et al. (2024) identify the gap
733 /// between detecting an issue and tracing it to a *named* equation as a
734 /// central roadblock for equation-oriented model debugging; threading
735 /// names through to the solver/debugger is the prerequisite for closing
736 /// it. See <https://doi.org/10.69997/sct.147875>.
737 pub var_names: Vec<String>,
738 /// Constraint names from the sibling `.row` file, index-aligned to `g`
739 /// (one name per line, row order). Empty when no `.row` file was found.
740 /// See [`NlProblem::var_names`] for why names are captured.
741 pub con_names: Vec<String>,
742 /// The `.nl` text this problem was parsed from, kept only when some
743 /// body was recognized as a quadratic and therefore has no tree of its
744 /// own. It is what makes [`NlProblem::con_expr`] able to hand back the
745 /// *exact* tree rather than a re-derived one: same bytes, same parser,
746 /// same `Expr`. `None` for [`NlProblem::from_expressions`] models and
747 /// for a parse that recognized nothing.
748 ///
749 /// The text is resident during parsing either way, so keeping it costs
750 /// nothing at the peak this is all in aid of — see
751 /// `dev-notes/quadratic-structure-exploitation.md` §0f.
752 pub src: Option<Arc<String>>,
753 /// The `V`-segment common subexpressions, by CSE-local index. Held so a
754 /// re-parse resolves `v<i>` (`i >= n`) to the *same* `Arc` the original
755 /// parse did — `HybridTape::build_multi` keys sharing on pointer
756 /// identity, so a rebuilt body that allocated fresh bodies would look
757 /// unshared.
758 pub cse_bodies: Vec<Arc<Expr>>,
759 /// Complementarity rows (`r`-segment kind 5, AMPL `complements`), in
760 /// row order. Each row's bounds are set to the side its body must lie
761 /// on (`[0, inf)` for a variable bounded below, `(-inf, 0]` for one
762 /// bounded above); the pairing itself is lowered by
763 /// `pounce_nlp::mpcc::MpccTnlp`, which a solve of a model with entries
764 /// here must go through. Empty for every model without complementarity.
765 pub complementarity: Vec<NlComplementarity>,
766}
767
768/// One `.nl` complementarity row: `body(row) complements x[var]`.
769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub struct NlComplementarity {
771 /// 0-based constraint row.
772 pub row: usize,
773 /// 0-based variable.
774 pub var: usize,
775 /// The `.nl` flag: which of the variable's bounds are finite (1 lower,
776 /// 2 upper, 3 both).
777 pub flags: u8,
778}
779
780/// The pieces of a model built in memory, as handed to
781/// [`NlProblem::from_expressions`].
782///
783/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
784/// split to fill in, because the AD tape treats a linear term exactly like
785/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
786/// optimization, not an evaluator requirement). `n` is taken from the length
787/// of `x_l`; `m` from the length of `constraints`.
788///
789/// One cost to that simplification, in *metadata* rather than values: with
790/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
791/// when its expression is literally `Const(0.0)`, so a genuinely linear row
792/// built here reports `NonLinear`. Presolve consumes that tag, and the
793/// direction is the safe one — it loses tightening it could have done, and
794/// never asserts linearity that does not hold — but a frontend that cares
795/// about presolve strength on linear rows should know the tag is
796/// pessimistic on this path.
797#[derive(Debug, Clone)]
798pub struct NlProblemParts {
799 /// `true` to minimize `objective`, `false` to maximize it. Matches
800 /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
801 /// so callers always see the minimization form.
802 pub minimize: bool,
803 /// Objective expression.
804 pub objective: Expr,
805 /// Constant offset added to the objective.
806 pub obj_constant: Number,
807 /// One expression per constraint row; row `i` is bounded by
808 /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
809 pub constraints: Vec<Expr>,
810 /// Variable bounds and starting point, each length `n`. Use `±1e19`
811 /// for "unbounded", the same sentinel the `.nl` reader emits.
812 pub x_l: Vec<Number>,
813 pub x_u: Vec<Number>,
814 pub x0: Vec<Number>,
815 /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
816 /// equality row.
817 pub g_l: Vec<Number>,
818 pub g_u: Vec<Number>,
819 /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
820 /// consumer falls back to indices (see [`NlProblem::var_names`]).
821 pub var_names: Vec<String>,
822 pub con_names: Vec<String>,
823}
824
825impl NlProblem {
826 /// Assemble a problem from expression trees, with no `.nl` file
827 /// anywhere in the loop (issue #469).
828 ///
829 /// A modeling frontend that already has its own expression DAG should
830 /// come in here rather than serialize to `.nl` and re-parse: the round
831 /// trip is not only slower, it is *lossy*, because `.nl` writers
832 /// routinely refuse operators this tape supports natively (`atan2`,
833 /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
834 ///
835 /// The result is an ordinary [`NlProblem`], so it feeds
836 /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
837 /// does: objective, gradient, constraints, Jacobian + structure,
838 /// Lagrangian Hessian + structure, and
839 /// [`NlTnlp::hessian_vector_product`].
840 ///
841 /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
842 /// — the latter would otherwise be an out-of-bounds read in the tape's
843 /// forward sweep, so it must be caught while it is still a diagnosable
844 /// user error.
845 pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
846 let NlProblemParts {
847 minimize,
848 objective,
849 obj_constant,
850 constraints,
851 x_l,
852 x_u,
853 x0,
854 g_l,
855 g_u,
856 var_names,
857 con_names,
858 } = parts;
859
860 let n = x_l.len();
861 let m = constraints.len();
862 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
863 if got == want {
864 Ok(())
865 } else {
866 Err(format!(
867 "from_expressions: {name} has length {got}, expected {want}"
868 ))
869 }
870 };
871 check("x_u", x_u.len(), n)?;
872 check("x0", x0.len(), n)?;
873 check("g_l", g_l.len(), m)?;
874 check("g_u", g_u.len(), m)?;
875 if !var_names.is_empty() {
876 check("var_names", var_names.len(), n)?;
877 }
878 if !con_names.is_empty() {
879 check("con_names", con_names.len(), m)?;
880 }
881
882 // Numeric validation, the same screen the `.nl` reader applies
883 // (gh #847). `lower_bound_present` / `upper_bound_present` are
884 // `is_finite() && ...`, so a caller that builds a model here and hands
885 // in a non-finite bound gets it silently dropped -- read as "no bound
886 // declared" -- exactly as a file containing `1e400` was. `-inf` on a
887 // lower side and `+inf` on an upper one are the sentinel said a
888 // different way and are normalized; everything else, `NaN` included,
889 // is refused.
890 let mut x_l = x_l;
891 let mut x_u = x_u;
892 let mut g_l = g_l;
893 let mut g_u = g_u;
894 for (i, v) in x_l.iter_mut().enumerate() {
895 *v = finite_bound_or_err(&format!("x_l[{i}]"), *v, true)
896 .map_err(|e| format!("from_expressions: {e}"))?;
897 }
898 for (i, v) in x_u.iter_mut().enumerate() {
899 *v = finite_bound_or_err(&format!("x_u[{i}]"), *v, false)
900 .map_err(|e| format!("from_expressions: {e}"))?;
901 }
902 for (i, v) in g_l.iter_mut().enumerate() {
903 *v = finite_bound_or_err(&format!("g_l[{i}]"), *v, true)
904 .map_err(|e| format!("from_expressions: {e}"))?;
905 }
906 for (i, v) in g_u.iter_mut().enumerate() {
907 *v = finite_bound_or_err(&format!("g_u[{i}]"), *v, false)
908 .map_err(|e| format!("from_expressions: {e}"))?;
909 }
910 for (i, v) in x0.iter().enumerate() {
911 finite_or_err(&format!("x0[{i}]"), *v).map_err(|e| format!("from_expressions: {e}"))?;
912 }
913 finite_or_err("obj_constant", obj_constant)
914 .map_err(|e| format!("from_expressions: {e}"))?;
915
916 // Structural validation. Memoized on `Cse` pointer identity so a
917 // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
918 let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
919 validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
920 for (i, c) in constraints.iter().enumerate() {
921 validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
922 }
923
924 Ok(NlProblem {
925 n,
926 m,
927 num_obj: 1,
928 minimize,
929 obj_nonlinear: NlBody::Tree(objective),
930 obj_linear: Vec::new(),
931 obj_constant,
932 con_nonlinear: constraints.into_iter().map(NlBody::Tree).collect(),
933 con_linear: vec![Vec::new(); m],
934 x_l,
935 x_u,
936 g_l,
937 g_u,
938 x0,
939 lambda0: vec![0.0; m],
940 suffixes: NlSuffixes::default(),
941 imported_funcs: Vec::new(),
942 ampl_options: Vec::new(),
943 // No header was read, so there is no census to report. Consumers
944 // fall back to walking the trees, which is what they would have
945 // to do here anyway.
946 nl_counts: None,
947 n_discrete: None,
948 var_names,
949 con_names,
950 // Built from trees, so every body has one and there is nothing
951 // to rebuild from.
952 src: None,
953 cse_bodies: Vec::new(),
954 complementarity: Vec::new(),
955 })
956 }
957
958 /// The objective's nonlinear body as an [`Expr`].
959 ///
960 /// Borrowed when the tree is resident, rebuilt when the parser
961 /// recognized the body and skipped building it. The rebuild re-parses
962 /// the body's own bytes with the same parser that produced the rest of
963 /// the model, so the result is the tree a non-recognizing parse would
964 /// have produced — structurally identical, coefficient bit patterns
965 /// included, and sharing the same `Cse` allocations.
966 ///
967 /// It is not free: it allocates the nodes the recognizer avoided. Reach
968 /// for [`NlBody::quad`] first if the coefficients are what you want.
969 pub fn obj_expr(&self) -> std::borrow::Cow<'_, Expr> {
970 self.body_expr(&self.obj_nonlinear, "objective")
971 }
972
973 /// Row `k`'s nonlinear body as an [`Expr`]. See [`Self::obj_expr`].
974 ///
975 /// # Panics
976 ///
977 /// Panics if `k >= m`, or if re-parsing a recognized body fails — the
978 /// latter cannot happen for a problem this crate produced (the bytes
979 /// parsed once already, by this code) and means the `NlProblem` was
980 /// assembled by hand with a `src` that does not match its bodies.
981 pub fn con_expr(&self, k: usize) -> std::borrow::Cow<'_, Expr> {
982 self.body_expr(&self.con_nonlinear[k], "constraint")
983 }
984
985 fn body_expr<'a>(&'a self, body: &'a NlBody, what: &str) -> std::borrow::Cow<'a, Expr> {
986 match body {
987 NlBody::Tree(e) => std::borrow::Cow::Borrowed(e),
988 NlBody::Quad(q) => {
989 let src = self
990 .src
991 .as_deref()
992 .unwrap_or_else(|| panic!("{what} body was recognized but no source is kept"));
993 std::borrow::Cow::Owned(
994 parse_body_fragment(&src[q.src.clone()], self.n, &self.cse_bodies)
995 .unwrap_or_else(|e| panic!("re-parsing a recognized {what} body: {e}")),
996 )
997 }
998 }
999 }
1000}
1001
1002/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
1003/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
1004///
1005/// Both are things the tape cannot recover from later. An out-of-range
1006/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
1007/// worse-looking than it is fatal: `from_expressions` has nowhere to put
1008/// the `F`-segment declarations an AMPL imported function needs
1009/// (`NlProblemParts` has no field for them, and the built problem's
1010/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
1011/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
1012/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
1013/// failure to "funcall id N has no F<N> declaration". Rejecting it here
1014/// says the true thing: this door does not carry external functions; go
1015/// through `read_nl` / `parse_nl_text` for a model that needs them.
1016///
1017/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
1018/// passing one set through a whole problem keeps the walk linear in
1019/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
1020/// repeat visit is sound because the caller aborts on the first violation:
1021/// reaching a body a second time proves the first visit found none.
1022fn validate_expr(
1023 e: &Expr,
1024 n: usize,
1025 seen: &mut std::collections::HashSet<*const Expr>,
1026) -> Result<(), String> {
1027 match e {
1028 Expr::Const(_) => Ok(()),
1029 Expr::Var(i) => {
1030 if *i < n {
1031 Ok(())
1032 } else {
1033 Err(format!("references Var({i}) but n = {n}"))
1034 }
1035 }
1036 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1037 validate_expr(a, n, seen)?;
1038 validate_expr(b, n, seen)
1039 }
1040 Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
1041 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1042 for a in args {
1043 validate_expr(a, n, seen)?;
1044 }
1045 Ok(())
1046 }
1047 Expr::Cond { cond, then_, else_ } => {
1048 validate_expr(cond, n, seen)?;
1049 validate_expr(then_, n, seen)?;
1050 validate_expr(else_, n, seen)
1051 }
1052 Expr::Cse(body) => {
1053 if seen.insert(Arc::as_ptr(body)) {
1054 validate_expr(body, n, seen)
1055 } else {
1056 Ok(())
1057 }
1058 }
1059 Expr::Funcall { id, .. } => Err(format!(
1060 "references AMPL imported function id {id}, which this path cannot \
1061 resolve: a problem built from expressions has no F-segment \
1062 declarations to bind it to. Load such a model with read_nl or \
1063 parse_nl_text instead."
1064 )),
1065 }
1066}
1067
1068/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
1069/// into dense vectors at problem load time so callers can index by
1070/// variable / constraint number directly. Empty maps when the `.nl`
1071/// file declared no suffixes.
1072#[derive(Debug, Clone, Default)]
1073pub struct NlSuffixes {
1074 /// Variable-level integer suffixes (kind = 0). Each vector has
1075 /// length `n_full` (problem variables).
1076 pub var_int: BTreeMap<String, Vec<Index>>,
1077 /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
1078 pub con_int: BTreeMap<String, Vec<Index>>,
1079 /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
1080 pub obj_int: BTreeMap<String, Vec<Index>>,
1081 /// Problem-level integer suffixes (kind = 3). Single value per name.
1082 pub problem_int: BTreeMap<String, Index>,
1083 /// Variable-level real suffixes (kind = 4). Length `n_full`.
1084 pub var_real: BTreeMap<String, Vec<Number>>,
1085 /// Constraint-level real suffixes (kind = 5). Length `m_full`.
1086 pub con_real: BTreeMap<String, Vec<Number>>,
1087 /// Objective-level real suffixes (kind = 6). Length `num_obj`.
1088 pub obj_real: BTreeMap<String, Vec<Number>>,
1089 /// Problem-level real suffixes (kind = 7). Single value per name.
1090 pub problem_real: BTreeMap<String, Number>,
1091}
1092
1093/// Deepest expression nesting the reader will build (gh#986). On a thread
1094/// whose stack is declared with [`set_parse_stack_budget`] the limit is
1095/// lowered to what that stack holds at the worst-case frame size; the CLI
1096/// runs on a 1 GiB thread and `pounce-py` on a 1 GiB worker (each falling
1097/// back to a smaller reservation, and a correspondingly lower limit, when the
1098/// OS refuses). A left-deep `o0` sum is read flat and does not count.
1099pub const MAX_PARSE_DEPTH: u32 = 40_000;
1100
1101thread_local! {
1102 /// Stack bytes the current thread offers the recursive reader
1103 /// (gh#986), set by whoever spawned it with a known stack. `None` (the
1104 /// default) means "unknown": [`DEFAULT_ASSUMED_STACK`] is assumed.
1105 static PARSE_STACK_BUDGET: std::cell::Cell<Option<usize>> =
1106 const { std::cell::Cell::new(None) };
1107}
1108
1109/// Tell the reader how much stack this thread has, so its depth guard fires
1110/// before the stack does at *any* build profile (gh#986). Call it first thing
1111/// on a thread created with an explicit stack size (the CLI's main thread, the
1112/// Python worker); `None` clears it.
1113pub fn set_parse_stack_budget(bytes: Option<usize>) {
1114 PARSE_STACK_BUDGET.with(|b| b.set(bytes));
1115}
1116
1117/// Stack the recursive reader (parse, tape build, drop glue) needs per
1118/// nesting level, as a worst case over build profiles. Measured on a
1119/// negation chain 39 900 deep: an unoptimised build (`opt-level = 0`, what
1120/// `cargo build` / `maturin develop` produce) fails at 512 MiB and passes at
1121/// 1 GiB, i.e. 13–26 KiB per level; optimised builds use well under 2 KiB.
1122const PARSE_LEVEL_BYTES: usize = if cfg!(debug_assertions) {
1123 32 * 1024
1124} else {
1125 4 * 1024
1126};
1127
1128/// Stack a thread is assumed to have when nobody declared one with
1129/// [`set_parse_stack_budget`] (gh#986 review). The first pass gave such a
1130/// thread the full [`MAX_PARSE_DEPTH`], sized for the CLI's 1 GiB thread, so a
1131/// library caller of [`read_nl_file`] on an ordinary thread, or the wasm
1132/// build, aborted on a stack overflow where the guard was meant to refuse.
1133/// The assumption is the smallest stack such a caller normally runs on: Rust's
1134/// default for a spawned thread (2 MiB, also what the test harness uses) on
1135/// native targets, and the 1 MiB `rustc` gives a wasm32 module.
1136const DEFAULT_ASSUMED_STACK: usize = if cfg!(target_arch = "wasm32") {
1137 1 << 20
1138} else {
1139 2 << 20
1140};
1141
1142/// The depth limit for the current thread: [`MAX_PARSE_DEPTH`], lowered to
1143/// what the thread's declared stack holds -- or, undeclared, what
1144/// [`DEFAULT_ASSUMED_STACK`] holds.
1145fn parse_depth_limit() -> u32 {
1146 let bytes = PARSE_STACK_BUDGET
1147 .with(|b| b.get())
1148 .unwrap_or(DEFAULT_ASSUMED_STACK);
1149 ((bytes / PARSE_LEVEL_BYTES).min(MAX_PARSE_DEPTH as usize)) as u32
1150}
1151
1152/// A run of binary `o0` longer than this is read as one n-ary `Sum`
1153/// (gh#986); shorter runs keep their binary tree.
1154const SUM_CHAIN_FLATTEN_MIN: usize = 64;
1155
1156/// Parse an `.nl` file from disk.
1157///
1158/// After parsing the `.nl` body, this also looks for AMPL's optional
1159/// sibling name files — `stub.col` (variable names) and `stub.row`
1160/// (constraint names), emitted only when the modeler sets
1161/// `option auxfiles rc;`. When present and well-formed they populate
1162/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
1163/// malformed the names stay empty and every downstream consumer falls
1164/// back to indices. Names are a diagnostic nicety, never load-blocking
1165/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1166pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
1167 // AMPL invokes a solver with an extensionless *stub* — e.g.
1168 // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
1169 // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
1170 // path as given is missing but appending `.nl` names an existing file,
1171 // resolve to that. This only ever *adds* a fallback: an existing path
1172 // is read verbatim, so nothing changes for callers that already pass a
1173 // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
1174 let resolved = if path.exists() {
1175 path.to_path_buf()
1176 } else {
1177 let with_nl = append_extension(path, "nl");
1178 if with_nl.exists() {
1179 with_nl
1180 } else {
1181 path.to_path_buf()
1182 }
1183 };
1184 let txt = std::fs::read_to_string(&resolved)
1185 .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
1186 // By value: a recognized body keeps a byte range into this text rather
1187 // than a tree, so it is moved into the problem instead of copied.
1188 let mut prob = parse_nl_string(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())?;
1189 prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
1190 prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
1191 Ok(prob)
1192}
1193
1194/// Append `.ext` to `path`'s full file name (AMPL stub convention:
1195/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
1196/// which would *replace* an existing extension. A stub that itself
1197/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
1198/// way AMPL names it.
1199fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
1200 let mut name = path.as_os_str().to_os_string();
1201 name.push(".");
1202 name.push(ext);
1203 std::path::PathBuf::from(name)
1204}
1205
1206/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
1207/// order. Returns the first `expected` names, or an empty vector when the
1208/// file is missing, unreadable, or has fewer than `expected` lines.
1209///
1210/// Returning empty (rather than erroring) on any mismatch is deliberate:
1211/// names are an optional diagnostic aid, so a missing or truncated file
1212/// must never block a solve. The `.take(expected)` also drops AMPL's
1213/// convention of appending the objective name after the constraint names
1214/// in `.row`, keeping the result aligned 1:1 with `g`.
1215fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
1216 let Ok(txt) = std::fs::read_to_string(path) else {
1217 return Vec::new();
1218 };
1219 let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
1220 if names.len() == expected {
1221 names
1222 } else {
1223 Vec::new()
1224 }
1225}
1226
1227/// The value of a constraint's nonlinear part when that part is a
1228/// constant, else `None`. Drives the constant-row-body fold in
1229/// [`parse_nl_text`].
1230///
1231/// "Constant" is decided by *evaluation*, not by syntax: a literal
1232/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
1233/// and is folded too.
1234///
1235/// Declines in two cases, both of which would make the fold unsound:
1236/// * The expression calls an AMPL imported function. Its value depends on
1237/// a shared library resolved much later (`nl_external::ExternalResolver`),
1238/// so it is not a parse-time constant even with constant arguments — and
1239/// [`eval_expr`] panics on `Funcall` rather than guess.
1240/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
1241/// a NaN or infinity into a bound would corrupt a row that is merely
1242/// infeasible; leaving the expression in place keeps it a solver-time
1243/// fact.
1244fn row_constant_value(e: &Expr) -> Option<Number> {
1245 // The identity zero the parser preallocates for every untouched row is
1246 // by far the most common input; settle it without walking anything.
1247 if let Expr::Const(c) = e {
1248 return c.is_finite().then_some(*c);
1249 }
1250 let mut vars: BTreeSet<usize> = BTreeSet::new();
1251 collect_vars(e, &mut vars);
1252 if !vars.is_empty() {
1253 return None;
1254 }
1255 let mut funcs: BTreeSet<usize> = BTreeSet::new();
1256 crate::nl_external::collect_funcall_ids(e, &mut funcs);
1257 if !funcs.is_empty() {
1258 return None;
1259 }
1260 // Variable-free, so no `Expr::Var` can index into the (empty) point.
1261 let v = eval_expr(e, &[]);
1262 v.is_finite().then_some(v)
1263}
1264
1265/// Parse `.nl` text content. Public so tests can use string literals.
1266///
1267/// Degree-2 bodies are recognized as the token stream is read, so their
1268/// `Expr` trees are never built (gh #588, Q5); `POUNCE_DBG_NO_QUAD=1`
1269/// turns that off and restores the pre-Q5 parse exactly, which is what the
1270/// A/B reference and gh #540's guard test need. See
1271/// [`parse_nl_text_with_quadratic`] for the same switch as a parameter.
1272pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
1273 parse_nl_text_with_quadratic(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
1274}
1275
1276/// [`parse_nl_text`] with parse-time quadratic recognition explicitly on
1277/// or off.
1278///
1279/// The env var the plain entry point reads is process-global, which is
1280/// exactly wrong for a differential test that has to parse the *same* text
1281/// both ways in one process and compare the results — so the knob is a
1282/// parameter here, mirroring [`NlTnlp::try_new_with_quadratic`].
1283pub fn parse_nl_text_with_quadratic(txt: &str, use_quadratic: bool) -> Result<NlProblem, String> {
1284 parse_nl_string(txt.to_string(), use_quadratic)
1285}
1286
1287/// [`parse_nl_text_with_quadratic`], taking the text by value.
1288///
1289/// A recognized body keeps a byte range into the source rather than a tree,
1290/// so the source outlives the parse. Taking ownership means the file
1291/// [`read_nl_file`] already read is *moved* into the problem instead of
1292/// copied: the text is resident during parsing either way, so this costs
1293/// nothing at the peak the phase is about.
1294pub fn parse_nl_string(txt: String, use_quadratic: bool) -> Result<NlProblem, String> {
1295 let src = Arc::new(txt);
1296 let mut p = Parser::new(&src, use_quadratic);
1297 p.parse_header()?;
1298 let n = p.n;
1299 let m = p.m;
1300 let num_obj = p.num_obj;
1301
1302 let mut con_nonlinear: Vec<NlBody> = (0..m).map(|_| NlBody::Tree(Expr::Const(0.0))).collect();
1303 let mut obj_nonlinear = NlBody::Tree(Expr::Const(0.0));
1304 let mut minimize = true;
1305 let mut obj_linear: Vec<(usize, Number)> = Vec::new();
1306 let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1307 let mut x_l = vec![-1e19; n];
1308 let mut x_u = vec![1e19; n];
1309 let mut g_l = vec![-1e19; m];
1310 let mut g_u = vec![1e19; m];
1311 let mut x0 = vec![0.0; n];
1312 let mut lambda0 = vec![0.0; m];
1313 let mut suffixes = NlSuffixes::default();
1314 let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
1315 // Segment presence, for the truncation check after the loop (gh#785).
1316 let mut saw_r = false;
1317 let mut complementarity: Vec<NlComplementarity> = Vec::new();
1318 let mut saw_b = false;
1319
1320 while let Some(line) = p.peek_segment_line() {
1321 let tag = line
1322 .trim_start()
1323 .chars()
1324 .next()
1325 .ok_or("unexpected blank segment header")?;
1326 match tag {
1327 'C' => {
1328 let (_hdr, rest) = p.eat_segment_header()?;
1329 let _ = rest;
1330 let idx = parse_segment_index(_hdr, 'C')?;
1331 if idx >= m {
1332 return Err(format!("C{idx} out of range; m={m}"));
1333 }
1334 con_nonlinear[idx] = p.parse_body()?;
1335 }
1336 'O' => {
1337 let (hdr, _rest) = p.eat_segment_header()?;
1338 let parts: Vec<&str> = hdr.split_whitespace().collect();
1339 if parts.len() < 2 {
1340 return Err(format!("malformed O-segment header: {hdr}"));
1341 }
1342 let idx = parse_segment_index(parts[0], 'O')?;
1343 let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
1344 if idx == 0 {
1345 minimize = kind == 0;
1346 obj_nonlinear = p.parse_body()?;
1347 } else {
1348 // Extra objectives are read but ignored.
1349 let _ = p.parse_expr()?;
1350 }
1351 }
1352 'r' => {
1353 p.eat_segment_header()?;
1354 saw_r = true;
1355 for i in 0..m {
1356 let line = p.next_data_line()?;
1357 if let Some(cc) = parse_complementarity_line(line)? {
1358 // `5 k j`: body complements x_j. The body lies on the
1359 // side the variable's finite bound implies; a free
1360 // variable (k = 0) leaves the body an equation.
1361 let (lo, hi) = match cc.flags {
1362 0 => (0.0, 0.0),
1363 1 => (0.0, 1e19),
1364 2 => (-1e19, 0.0),
1365 _ => (-1e19, 1e19),
1366 };
1367 g_l[i] = lo;
1368 g_u[i] = hi;
1369 if cc.flags != 0 {
1370 complementarity.push(NlComplementarity { row: i, ..cc });
1371 }
1372 continue;
1373 }
1374 let (lo, hi) = parse_bound_line(line)?;
1375 g_l[i] = lo;
1376 g_u[i] = hi;
1377 }
1378 }
1379 'b' => {
1380 p.eat_segment_header()?;
1381 saw_b = true;
1382 for i in 0..n {
1383 let line = p.next_data_line()?;
1384 let (lo, hi) = parse_bound_line(line)?;
1385 x_l[i] = lo;
1386 x_u[i] = hi;
1387 }
1388 }
1389 'k' => {
1390 // Column counts in the Jacobian; we don't need their
1391 // values for evaluation (the J segments give explicit
1392 // lists), but we must consume exactly as many data lines
1393 // as follow or the segment stream desyncs. The `.nl`
1394 // format writes that line count in the header itself
1395 // (`k<count>`), and the standard value is `n-1`. Read the
1396 // declared count rather than assuming it: a file with a
1397 // nonstandard count would otherwise leave us reading the
1398 // wrong number of lines, swallowing a later segment header
1399 // (or stopping short) and failing with a confusing,
1400 // far-removed error. Validate against the expected `n-1`
1401 // so a mismatch surfaces here, clearly, at its source.
1402 let (hdr, _) = p.eat_segment_header()?;
1403 let declared = parse_segment_index(hdr, 'k')?;
1404 let expected = if n == 0 { 0 } else { n - 1 };
1405 if declared != expected {
1406 return Err(format!(
1407 "k-segment declares {declared} column-count lines but \
1408 the standard count for n={n} variables is {expected}"
1409 ));
1410 }
1411 for _ in 0..declared {
1412 p.next_data_line()?;
1413 }
1414 }
1415 'J' => {
1416 let (hdr, _) = p.eat_segment_header()?;
1417 let parts: Vec<&str> = hdr.split_whitespace().collect();
1418 if parts.len() < 2 {
1419 return Err(format!("malformed J-segment header: {hdr}"));
1420 }
1421 let row = parse_segment_index(parts[0], 'J')?;
1422 let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
1423 if row >= m {
1424 return Err(format!("J{row} out of range"));
1425 }
1426 for _ in 0..nz {
1427 let line = p.next_data_line()?;
1428 let (var, coef) = parse_var_coef(line)?;
1429 // Validate the column index here: an out-of-range `var`
1430 // would otherwise be stored and panic as a slice OOB
1431 // (`x[var]`) during constraint evaluation. Mirror the
1432 // clean parse error used for the row index above.
1433 if var >= n {
1434 return Err(format!(
1435 "J{row} entry variable index {var} out of range (n={n})"
1436 ));
1437 }
1438 con_linear[row].push((var, coef));
1439 }
1440 }
1441 'G' => {
1442 let (hdr, _) = p.eat_segment_header()?;
1443 let parts: Vec<&str> = hdr.split_whitespace().collect();
1444 if parts.len() < 2 {
1445 return Err(format!("malformed G-segment header: {hdr}"));
1446 }
1447 let idx = parse_segment_index(parts[0], 'G')?;
1448 let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
1449 let mut acc = Vec::with_capacity(nz);
1450 for _ in 0..nz {
1451 let line = p.next_data_line()?;
1452 let (var, coef) = parse_var_coef(line)?;
1453 // Same as J: reject an out-of-range gradient column index
1454 // up front rather than letting it panic on `x[var]` later.
1455 if var >= n {
1456 return Err(format!(
1457 "G{idx} entry variable index {var} out of range (n={n})"
1458 ));
1459 }
1460 acc.push((var, coef));
1461 }
1462 if idx == 0 {
1463 obj_linear = acc;
1464 }
1465 }
1466 'x' => {
1467 let (hdr, _) = p.eat_segment_header()?;
1468 let parts: Vec<&str> = hdr.split_whitespace().collect();
1469 let nx: usize = parts
1470 .first()
1471 .and_then(|s| s.trim_start_matches('x').parse().ok())
1472 .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
1473 for _ in 0..nx {
1474 let line = p.next_data_line()?;
1475 let (idx, val) = parse_var_coef(line)?;
1476 // Reject out-of-range indices as a parse error, matching
1477 // J/G strictness, rather than silently dropping the entry
1478 // (which hides a corrupt initial-primal segment).
1479 if idx >= n {
1480 return Err(format!(
1481 "x-segment variable index {idx} out of range (n={n})"
1482 ));
1483 }
1484 x0[idx] = val;
1485 }
1486 }
1487 'd' => {
1488 let (hdr, _) = p.eat_segment_header()?;
1489 let parts: Vec<&str> = hdr.split_whitespace().collect();
1490 let nd: usize = parts
1491 .first()
1492 .and_then(|s| s.trim_start_matches('d').parse().ok())
1493 .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
1494 for _ in 0..nd {
1495 let line = p.next_data_line()?;
1496 let (idx, val) = parse_var_coef(line)?;
1497 // Reject out-of-range indices as a parse error, matching
1498 // J/G strictness, rather than silently dropping the entry
1499 // (which hides a corrupt initial-dual segment).
1500 if idx >= m {
1501 return Err(format!(
1502 "d-segment constraint index {idx} out of range (m={m})"
1503 ));
1504 }
1505 // A `d` entry is in AMPL's dual sign (`d obj / d b`, the
1506 // sign a `.sol` dual block carries), and pounce's
1507 // multiplier is that of `L = f + lambda' g`, i.e. `-dual`.
1508 // `sol_writer` negates on the way out; negate on the way
1509 // in so a `.nl` start and a `.sol` result share one sign
1510 // (gh#1033; matches Ipopt's AMPL driver).
1511 lambda0[idx] = -val;
1512 }
1513 }
1514 'V' => p.parse_v_segment()?,
1515 'S' => {
1516 parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
1517 }
1518 'F' => {
1519 // AMPL imported (external) function declaration:
1520 // `F<k> <type> <nargs> <name>`.
1521 let (hdr, _rest) = p.eat_segment_header()?;
1522 let parts: Vec<&str> = hdr.split_whitespace().collect();
1523 if parts.is_empty() {
1524 return Err(format!("malformed F-segment header: '{hdr}'"));
1525 }
1526 let id = parse_segment_index(parts[0], 'F')?;
1527 let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1528 let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1529 let name = parts.get(3).copied().unwrap_or("").to_string();
1530 imported_funcs.push(ImportedFunc {
1531 id,
1532 kind,
1533 nargs,
1534 name,
1535 });
1536 }
1537 other => return Err(format!("unknown .nl segment tag '{other}'")),
1538 }
1539 }
1540
1541 // A `.nl` file that ends early is not a smaller model — it is a corrupt
1542 // one, and every segment the truncation ate is a piece of the problem
1543 // that silently reverts to a default: a dropped `r` leaves every row at
1544 // ±1e19 (i.e. unconstrained), a dropped `b` leaves every variable free,
1545 // a dropped `J` leaves every row's linear part empty. The segment loop
1546 // cannot tell those defaults from a legitimately absent segment, so the
1547 // truncated model solves, and reports `SolveSucceeded` with a
1548 // confidently wrong objective — which is strictly worse than the parse
1549 // error every other malformed input already gets (gh#785). The header
1550 // declares enough to tell the two apart; check it here, once, against
1551 // what the segments actually delivered.
1552 //
1553 // `r` and `b` are unconditional in the format: AMPL writes both whenever
1554 // the model has rows / columns at all, free rows and free variables
1555 // included — those get the `3` ("no bounds") code, not an omitted line.
1556 if m > 0 && !saw_r {
1557 return Err(format!(
1558 "missing `r` (constraint-bounds) segment for a model declaring {m} \
1559 constraint(s): the .nl file is truncated or corrupt"
1560 ));
1561 }
1562 if n > 0 && !saw_b {
1563 return Err(format!(
1564 "missing `b` (variable-bounds) segment for a model declaring {n} \
1565 variable(s): the .nl file is truncated or corrupt"
1566 ));
1567 }
1568 // The `J` segments must deliver exactly the Jacobian nonzero count the
1569 // header declares — `nzc` is the sum of their lengths by construction,
1570 // nonlinear-only columns included (they are written with a zero
1571 // coefficient, not omitted). This is the check that catches a truncation
1572 // landing *after* `r` and `b`, where the bounds are all present and only
1573 // the coefficients are gone.
1574 if let Some(declared) = p.declared_jac_nnz {
1575 let parsed: usize = con_linear.iter().map(Vec::len).sum();
1576 if parsed != declared {
1577 return Err(format!(
1578 "header declares {declared} Jacobian nonzero(s) but the J \
1579 segments supply {parsed}: the .nl file is truncated or corrupt"
1580 ));
1581 }
1582 }
1583
1584 // Normalize constant row bodies into the row bounds, so that
1585 // "the nonlinear part is the identity zero" and "this row's body is
1586 // Σaⱼxⱼ" mean the same thing for every downstream consumer.
1587 //
1588 // A `C<i>` segment holding a variable-free expression (`n3.0`, or
1589 // anything that evaluates to a constant such as `o0 n1 n2`) is an
1590 // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
1591 // which every consumer reads as "nonlinear": the linearity predicate
1592 // (`get_constraints_linearity`), which is what makes presolve's
1593 // linear-equality reduction decline the row; the CLI problem
1594 // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
1595 // polynomial walk absorbs a bare literal but not a constant it has to
1596 // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
1597 // NLP and never reached the convex path); and the FBBT translator.
1598 // Folding here fixes all of them at once, with no per-consumer audit.
1599 //
1600 // The shift is exact and invisible from outside: the body drops by `c`
1601 // and each bound drops by `c` with it, so the feasible set, the active
1602 // set, and the duals are unchanged (`gh #492`). This is the same
1603 // normalization `qp_extract::analyze_quadratic_full` already performs
1604 // ad hoc via `const_shift`, promoted to the parse boundary.
1605 for i in 0..m {
1606 // A recognized body is degree 2, so it is not a constant row and
1607 // this fold has nothing to do with it.
1608 let Some(tree) = con_nonlinear[i].tree() else {
1609 continue;
1610 };
1611 let Some(c) = row_constant_value(tree) else {
1612 continue;
1613 };
1614 // Presence is directional (gh #401): shifting an *absent* bound
1615 // would turn the ±1e19 sentinel into a real bound for `c < 0`
1616 // (lower) or `c > 0` (upper), inventing a constraint. Leave the
1617 // sentinels alone.
1618 if lower_bound_present(g_l[i]) {
1619 g_l[i] -= c;
1620 }
1621 if upper_bound_present(g_u[i]) {
1622 g_u[i] -= c;
1623 }
1624 con_nonlinear[i] = NlBody::Tree(Expr::Const(0.0));
1625 }
1626
1627 // The source is kept only when something in it has no tree of its own.
1628 // A model with nothing recognized is byte-for-byte the pre-Q5 problem,
1629 // extra field included.
1630 let any_recognized =
1631 obj_nonlinear.quad().is_some() || con_nonlinear.iter().any(|b| b.quad().is_some());
1632 let kept_src = any_recognized.then(|| Arc::clone(&src));
1633
1634 // A `d` entry was stored as `-dual` above, which is the engine's
1635 // multiplier only for a minimize model. A maximize model is solved as
1636 // `min -f`, and the CLI writes its `.sol` dual as `-sigma * lambda`
1637 // (sigma = -1, gh#959), so the inverse is `lambda = -sigma * dual`.
1638 // Applied here because the `O` segment fixing the sense may follow `d`.
1639 if !minimize {
1640 for v in lambda0.iter_mut() {
1641 *v = -*v;
1642 }
1643 }
1644 Ok(NlProblem {
1645 n,
1646 m,
1647 num_obj,
1648 minimize,
1649 obj_nonlinear,
1650 obj_linear,
1651 obj_constant: 0.0,
1652 con_nonlinear,
1653 con_linear,
1654 x_l,
1655 x_u,
1656 g_l,
1657 g_u,
1658 x0,
1659 lambda0,
1660 suffixes,
1661 ampl_options: p.ampl_options.clone(),
1662 nl_counts: p.nl_counts,
1663 n_discrete: p.n_discrete,
1664 imported_funcs,
1665 // `.nl` text carries no names; `read_nl_file` fills these from the
1666 // sibling `.col`/`.row` files when present.
1667 var_names: Vec::new(),
1668 con_names: Vec::new(),
1669 src: kept_src,
1670 cse_bodies: p.cses.clone(),
1671 complementarity,
1672 })
1673}
1674
1675/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
1676/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
1677///
1678/// ```text
1679/// S<kind> <nentries> <suffix_name>
1680/// <idx> <value> ... nentries lines
1681/// ```
1682///
1683/// `<kind>` is a 3-bit encoding:
1684/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
1685/// 2 = objectives, 3 = problem-level.
1686/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
1687///
1688/// Sparse entries scatter into a freshly-allocated dense vector (zero
1689/// default), sized for the target dimension. Problem-level suffixes
1690/// (kind = 3 / 7) carry a single value.
1691fn parse_suffix_segment(
1692 p: &mut Parser,
1693 n: usize,
1694 m: usize,
1695 num_obj: usize,
1696 out: &mut NlSuffixes,
1697) -> Result<(), String> {
1698 let (hdr, _) = p.eat_segment_header()?;
1699 let parts: Vec<&str> = hdr.split_whitespace().collect();
1700 if parts.len() < 3 {
1701 return Err(format!(
1702 "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
1703 ));
1704 }
1705 let kind_str = parts[0].trim_start_matches('S');
1706 let kind: u32 = kind_str
1707 .parse()
1708 .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
1709 let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
1710 let name = parts[2].to_string();
1711
1712 let is_real = (kind & 0x4) != 0;
1713 let target = kind & 0x3;
1714 let target_dim = match target {
1715 0 => n,
1716 1 => m,
1717 2 => num_obj,
1718 3 => 0, // problem-level — entries are single-valued (idx=0)
1719 _ => unreachable!("kind & 0x3 is in 0..=3"),
1720 };
1721
1722 // Pre-allocate dense buffers (default zero). Problem-level kinds
1723 // (3 / 7) hold a single scalar — we still read the (idx, value)
1724 // pairs but only the value field is meaningful.
1725 let mut int_buf: Vec<Index> = if !is_real && target != 3 {
1726 vec![0; target_dim]
1727 } else {
1728 Vec::new()
1729 };
1730 let mut real_buf: Vec<Number> = if is_real && target != 3 {
1731 vec![0.0; target_dim]
1732 } else {
1733 Vec::new()
1734 };
1735 let mut problem_int: Index = 0;
1736 let mut problem_real: Number = 0.0;
1737
1738 for _ in 0..nentries {
1739 let line = p.next_data_line()?;
1740 let parts: Vec<&str> = line.split_whitespace().collect();
1741 if parts.len() < 2 {
1742 return Err(format!(
1743 "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
1744 ));
1745 }
1746 let idx: usize = parts[0]
1747 .parse()
1748 .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
1749 if target != 3 && idx >= target_dim {
1750 return Err(format!(
1751 "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
1752 ));
1753 }
1754 if is_real {
1755 let v: Number = parts[1]
1756 .parse()
1757 .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
1758 if target == 3 {
1759 problem_real = v;
1760 } else {
1761 real_buf[idx] = v;
1762 }
1763 } else {
1764 let v: Index = parts[1]
1765 .parse()
1766 .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
1767 if target == 3 {
1768 problem_int = v;
1769 } else {
1770 int_buf[idx] = v;
1771 }
1772 }
1773 }
1774
1775 match (target, is_real) {
1776 (0, false) => {
1777 out.var_int.insert(name, int_buf);
1778 }
1779 (1, false) => {
1780 out.con_int.insert(name, int_buf);
1781 }
1782 (2, false) => {
1783 out.obj_int.insert(name, int_buf);
1784 }
1785 (3, false) => {
1786 out.problem_int.insert(name, problem_int);
1787 }
1788 (0, true) => {
1789 out.var_real.insert(name, real_buf);
1790 }
1791 (1, true) => {
1792 out.con_real.insert(name, real_buf);
1793 }
1794 (2, true) => {
1795 out.obj_real.insert(name, real_buf);
1796 }
1797 (3, true) => {
1798 out.problem_real.insert(name, problem_real);
1799 }
1800 _ => unreachable!(),
1801 }
1802 Ok(())
1803}
1804
1805fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1806 let trimmed = s.trim_start_matches(tag);
1807 trimmed
1808 .parse()
1809 .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1810}
1811
1812// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1813// `G` / `x` / `d` data line, so between them they see every Jacobian and
1814// gradient nonzero in the file. Both walk the whitespace iterator
1815// directly instead of collecting a `Vec<&str>` first — that collect was
1816// a heap allocation per line on top of the one the reader used to make
1817// handing the line over.
1818/// Refuse a non-finite number read out of a `.nl` file (gh #847).
1819///
1820/// `str::parse::<f64>()` accepts `inf`, `-inf` and `nan`, and it also *returns*
1821/// `inf` for any literal that overflows the type — `1e400` is a plausible thing
1822/// for a model generator to write. Nothing downstream treats such a value as an
1823/// error, and in one place it is actively misread: `lower_bound_present` /
1824/// `upper_bound_present` are `is_finite() && ...`, so a non-finite bound is
1825/// indistinguishable from a bound that was never declared, and is silently
1826/// dropped. On a model that a lower bound of `1e300` makes infeasible, the same
1827/// bound written `1e400` returned `EXIT: Optimal Solution Found.` with exit code
1828/// 0. A `nan` is worse: it propagates into the answer, and the solve reports
1829/// `Objective: nan` under `Solve_Succeeded`.
1830///
1831/// Ipopt refuses this input ("Invalid number"), POUNCE's own NLP arm refuses it,
1832/// and `pounce.solve_qp` refuses a non-finite bound with a bespoke `ValueError`.
1833/// There is no reading on which `Optimal` is the intended answer, so the reader
1834/// refuses it too.
1835fn finite_or_err(what: &str, v: Number) -> Result<Number, String> {
1836 if v.is_finite() {
1837 Ok(v)
1838 } else {
1839 Err(format!(
1840 "invalid number: {what} is {v}, which is not finite"
1841 ))
1842 }
1843}
1844
1845/// The same screen for a *bound* slot, where one non-finite value has an
1846/// unambiguous meaning and is normalized instead of refused.
1847///
1848/// `.nl` states "no bound" with a bound **kind** (1 = upper only, 2 = lower
1849/// only, 3 = free), so a non-finite number in a bound slot is a corrupt value
1850/// rather than a notation — with one exception per side. `-inf` in a *lower*
1851/// slot and `+inf` in an *upper* slot say precisely what the `±1e19` sentinel
1852/// says, and a writer that emits them means it, so they map to the sentinel.
1853///
1854/// Everything else is refused, and the asymmetry is the whole point: `+inf` as
1855/// a *lower* bound is the gh #847 case. It is not "unbounded below" — it is an
1856/// empty box, and reading it as "absent" is what turned an infeasible model
1857/// into an `Optimal` one. `NaN` is refused on either side, having no meaning at
1858/// all.
1859fn finite_bound_or_err(what: &str, v: Number, lower: bool) -> Result<Number, String> {
1860 if v.is_finite() {
1861 return Ok(v);
1862 }
1863 if lower && v == Number::NEG_INFINITY {
1864 return Ok(-1e19);
1865 }
1866 if !lower && v == Number::INFINITY {
1867 return Ok(1e19);
1868 }
1869 Err(format!(
1870 "invalid number: {what} is {v}, which is not finite (a `.nl` file \
1871 states an absent bound with a bound kind of 1, 2 or 3, not with a \
1872 non-finite value)"
1873 ))
1874}
1875
1876/// `Some` for an `r`-segment complementarity line `5 k j` (`k` the variable's
1877/// finite-bound flags, `j` the 1-based variable), `None` for any other kind.
1878/// The row index is filled in by the caller.
1879fn parse_complementarity_line(line: &str) -> Result<Option<NlComplementarity>, String> {
1880 let mut parts = line.split_whitespace();
1881 if parts.next() != Some("5") {
1882 return Ok(None);
1883 }
1884 let (Some(k), Some(j)) = (parts.next(), parts.next()) else {
1885 return Err(format!("complementarity line needs `5 k var`: '{line}'"));
1886 };
1887 let flags: u8 = k
1888 .parse()
1889 .map_err(|e| format!("complementarity flags: {e}"))?;
1890 if flags > 3 {
1891 return Err(format!(
1892 "complementarity flags {flags} not in 0..=3: '{line}'"
1893 ));
1894 }
1895 let var: usize = j
1896 .parse()
1897 .map_err(|e| format!("complementarity variable: {e}"))?;
1898 if var == 0 {
1899 return Err(format!(
1900 "complementarity variable index is 1-based, got 0: '{line}'"
1901 ));
1902 }
1903 Ok(Some(NlComplementarity {
1904 row: 0,
1905 var: var - 1,
1906 flags,
1907 }))
1908}
1909
1910fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1911 let mut parts = line.split_whitespace();
1912 let kind: i32 = parts
1913 .next()
1914 .ok_or("empty bound line")?
1915 .parse()
1916 .map_err(|e| format!("bound kind: {e}"))?;
1917 let lo;
1918 let hi;
1919 match kind {
1920 0 => {
1921 // 0 lo hi
1922 let (l, h) = (parts.next(), parts.next());
1923 let (Some(l), Some(h)) = (l, h) else {
1924 return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1925 };
1926 lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1927 hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1928 }
1929 1 => {
1930 // 1 hi
1931 let Some(h) = parts.next() else {
1932 return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1933 };
1934 lo = -1e19;
1935 hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1936 }
1937 2 => {
1938 // 2 lo
1939 let Some(l) = parts.next() else {
1940 return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1941 };
1942 lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1943 hi = 1e19;
1944 }
1945 3 => {
1946 // 3 (free)
1947 lo = -1e19;
1948 hi = 1e19;
1949 }
1950 4 => {
1951 // 4 eq
1952 let Some(v) = parts.next() else {
1953 return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1954 };
1955 // An equality has no "absent" side, so neither infinity is a
1956 // notation here and both are refused.
1957 let v: Number = finite_or_err("eq bound", v.parse().map_err(|e| format!("eq: {e}"))?)?;
1958 lo = v;
1959 hi = v;
1960 }
1961 5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1962 other => return Err(format!("unknown bound kind {other}")),
1963 }
1964 Ok((lo, hi))
1965}
1966
1967fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1968 let mut parts = line.split_whitespace();
1969 let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1970 return Err(format!("malformed var/coef line: '{line}'"));
1971 };
1972 let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1973 let c: Number = finite_or_err("coefficient", c.parse().map_err(|e| format!("coef: {e}"))?)?;
1974 Ok((v, c))
1975}
1976
1977/// Build an [`NlCounts`] from `.nl` header lines 3 (`nlc nlo`) and 5
1978/// (`nlvc nlvo nlvb`).
1979///
1980/// Returns `None` unless both lines carry the full complement of
1981/// non-negative integers, so a truncated or non-conforming header reads as
1982/// "unknown" rather than as zeros — "no nonlinear variables" is a claim, and
1983/// a header that failed to parse has not made it.
1984fn parse_nl_counts(line3: &str, line5: &str) -> Option<NlCounts> {
1985 let nums = |line: &str, want: usize| -> Option<Vec<usize>> {
1986 let v: Vec<usize> = line
1987 .split_whitespace()
1988 .take(want)
1989 .map(str::parse)
1990 .collect::<Result<_, _>>()
1991 .ok()?;
1992 (v.len() == want).then_some(v)
1993 };
1994 let cons_objs = nums(line3, 2)?;
1995 let vars = nums(line5, 3)?;
1996 Some(NlCounts {
1997 nl_cons: cons_objs[0],
1998 nl_objs: cons_objs[1],
1999 nl_vars_cons: vars[0],
2000 nl_vars_objs: vars[1],
2001 nl_vars_both: vars[2],
2002 })
2003}
2004
2005/// Header line 7: `nbv niv nlvbi nlvci nlvoi` (gh#987).
2006///
2007/// In AMPL's convention `nbv` and `niv` count only the discrete variables
2008/// that appear **linearly**; a discrete variable that appears nonlinearly is
2009/// counted in `nlvbi` / `nlvci` / `nlvoi` instead, *not* in `nbv + niv`. So a
2010/// MINLP whose integers all appear nonlinearly has `nbv = niv = 0` — reading
2011/// only those two fields reports it as continuous.
2012#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2013pub struct DiscreteCensus {
2014 /// `nbv`: binary variables appearing only linearly.
2015 pub binary: usize,
2016 /// `niv`: other integer variables appearing only linearly.
2017 pub integer: usize,
2018 /// `nlvbi`: integer variables nonlinear in both constraints and objectives.
2019 pub nl_both: usize,
2020 /// `nlvci`: integer variables nonlinear in constraints only.
2021 pub nl_cons: usize,
2022 /// `nlvoi`: integer variables nonlinear in objectives only.
2023 pub nl_objs: usize,
2024}
2025
2026impl DiscreteCensus {
2027 /// Total declared discrete variables, linear and nonlinear
2028 /// (`nbv + niv + nlvbi + nlvci + nlvoi`).
2029 pub fn total(&self) -> usize {
2030 self.binary + self.integer + self.nonlinear_discrete()
2031 }
2032
2033 /// Discrete variables that appear nonlinearly (`nlvbi + nlvci + nlvoi`).
2034 pub fn nonlinear_discrete(&self) -> usize {
2035 self.nl_both + self.nl_cons + self.nl_objs
2036 }
2037
2038 /// The 0-based columns of every discrete variable, from Gay's variable
2039 /// ordering ("Writing .nl Files", table 4):
2040 ///
2041 /// 1. nonlinear in both constraints and objectives — `nlvb` columns, the
2042 /// last `nlvbi` of them integer;
2043 /// 2. nonlinear in constraints only — `nlvc − nlvb` columns, the last
2044 /// `nlvci` integer;
2045 /// 3. nonlinear in objectives only — `nlvo − nlvc` columns (when
2046 /// positive), the last `nlvoi` integer;
2047 /// 4. linear variables, ending in `nbv` binary then `niv` integer, which
2048 /// are therefore the last `nbv + niv` columns of the model.
2049 ///
2050 /// `None` when the columns cannot be identified: a nonlinear integer
2051 /// count is non-zero but the header's line-5 census is missing, or the
2052 /// counts are inconsistent with the block sizes. Callers must report that
2053 /// as *not checked*, never as passed.
2054 pub fn integer_columns(&self, n: usize, counts: Option<NlCounts>) -> Option<Vec<usize>> {
2055 let lin = self.binary + self.integer;
2056 if lin > n {
2057 return None;
2058 }
2059 let mut cols = Vec::with_capacity(self.total());
2060 if self.nonlinear_discrete() > 0 {
2061 let c = counts?;
2062 let nlvb = c.nl_vars_both;
2063 let nlvc = c.nl_vars_cons;
2064 let nlvo = c.nl_vars_objs;
2065 if nlvc < nlvb {
2066 return None;
2067 }
2068 let b3 = nlvo.saturating_sub(nlvc);
2069 let blocks = [
2070 (0, nlvb, self.nl_both),
2071 (nlvb, nlvc - nlvb, self.nl_cons),
2072 (nlvc, b3, self.nl_objs),
2073 ];
2074 for (start, len, ints) in blocks {
2075 if ints > len || start + len > n - lin {
2076 return None;
2077 }
2078 cols.extend(start + len - ints..start + len);
2079 }
2080 }
2081 cols.extend(n - lin..n);
2082 Some(cols)
2083 }
2084}
2085
2086struct Parser<'a> {
2087 lines: Vec<&'a str>,
2088 /// Start address and length of the source text. A recognized body
2089 /// records the byte *range* it consumed so it can be re-parsed later
2090 /// (see [`QuadBody::src`]), and every line borrows from this buffer, so
2091 /// one subtraction per line recovers its offset.
2092 ///
2093 /// Deliberately not a per-line offset table: on a 119 MB generated
2094 /// `qcqp500-3c` the file is ~17 M lines, and a `Vec<usize>` beside the
2095 /// existing `Vec<&str>` would add 136 MB to the peak this phase exists
2096 /// to reduce.
2097 txt_base: usize,
2098 txt_len: usize,
2099 pos: usize,
2100 n: usize,
2101 m: usize,
2102 num_obj: usize,
2103 /// Number of AMPL imported (external) functions declared in the header.
2104 n_funcs: usize,
2105 /// Header lines 3 and 5, when both parsed. See [`NlCounts`].
2106 nl_counts: Option<NlCounts>,
2107 /// `(nbv, niv)` from header line 7.
2108 n_discrete: Option<DiscreteCensus>,
2109 /// `nzc` from header line 8: the number of Jacobian nonzeros the file
2110 /// *declares*. [`parse_nl_string`] cross-checks it against the number the
2111 /// `J` segments actually deliver, which is how a file truncated before
2112 /// them is told from a model that genuinely has none (gh#785). `None`
2113 /// when the header does not carry it in the documented shape.
2114 declared_jac_nnz: Option<usize>,
2115 ampl_options: Vec<i64>,
2116 /// Common subexpressions (`V` segments). Index in this vec is the
2117 /// CSE-local index, i.e. the global `.nl` index minus `n`.
2118 cses: Vec<Arc<Expr>>,
2119 /// Recognize degree-2 bodies from the token stream instead of building
2120 /// their trees (gh #588, Q5). Off restores the pre-Q5 parse exactly.
2121 quad_enabled: bool,
2122 /// Per-CSE answers the streaming recognizer needs about a `V` body it
2123 /// may be handed a reference to, all keyed by CSE-local index: the
2124 /// body's own degree-≤2 form, whether it is legal on a sum spine,
2125 /// whether it is legal *inside* a monomial, and its variable support.
2126 ///
2127 /// These are computed from the built `V` body with the same functions
2128 /// `NlTnlp` would apply to a whole row, so a reference costs a lookup
2129 /// and cannot drift from what the tree walk would have said. `V` bodies
2130 /// keep their trees regardless — they are shared, so the memory is
2131 /// amortized over every reference, and dropping them would mean
2132 /// rebuilding them for the rows that are *not* recognized.
2133 cse_quad: Vec<Option<Quad2>>,
2134 cse_sum_ok: Vec<bool>,
2135 cse_mono_ok: Vec<bool>,
2136 cse_vars: Vec<Vec<u32>>,
2137 cse_depth: Vec<u32>,
2138 /// gh#986 review: the depth of every `V` body *through* the defined
2139 /// variables it references (a `Cse` counting as its body), keyed by the
2140 /// body's `Arc` pointer. Always kept, whatever `quad_enabled` says: it is
2141 /// what bounds the recursion of every later pass that walks through a
2142 /// `Cse` (tape build, `collect_vars`, evaluation, drop glue), and it makes
2143 /// [`expr_tree_depth`] linear in the body rather than in the whole chain.
2144 cse_depth_memo: std::collections::HashMap<*const Expr, u32>,
2145 /// `V` body pointer -> its index in `cses` (and in the `cse_*` caches),
2146 /// so the parse-time passes can read an earlier body's verdict instead of
2147 /// re-walking it (gh#986 review).
2148 cse_index: std::collections::HashMap<*const Expr, usize>,
2149 /// Current recursion depth of `parse_expr` (gh#986).
2150 parse_depth: u32,
2151 /// Depth at which `parse_expr` refuses (gh#986); see [`parse_depth_limit`].
2152 max_depth: u32,
2153}
2154
2155/// One pending operator on the streaming recognizer's stack.
2156struct QFrame {
2157 op: QOp,
2158 /// Operands still to be read.
2159 remaining: usize,
2160 /// Read this frame's operands in monomial mode — no `+`/`-` may appear
2161 /// below it. See [`crate::nl_quadratic::is_expanded_quadratic`].
2162 mono: bool,
2163}
2164
2165/// The operators the streaming recognizer accepts. One variant per shape
2166/// that [`crate::nl_quadratic::recognize_expr`] handles; everything else
2167/// makes it bail.
2168enum QOp {
2169 Neg,
2170 Add,
2171 Sub,
2172 Mul,
2173 Div,
2174 /// `o5`/`o81`/`o83`: base and exponent both read.
2175 Pow,
2176 /// `o82`: square, exponent implicit.
2177 Square,
2178 Sum(usize),
2179}
2180
2181impl<'a> Parser<'a> {
2182 fn new(txt: &'a str, quad_enabled: bool) -> Self {
2183 let lines: Vec<&str> = txt.lines().collect();
2184 Self {
2185 lines,
2186 txt_base: txt.as_ptr() as usize,
2187 txt_len: txt.len(),
2188 pos: 0,
2189 n: 0,
2190 m: 0,
2191 num_obj: 0,
2192 n_funcs: 0,
2193 nl_counts: None,
2194 n_discrete: None,
2195 declared_jac_nnz: None,
2196 ampl_options: Vec::new(),
2197 cses: Vec::new(),
2198 quad_enabled,
2199 cse_quad: Vec::new(),
2200 cse_sum_ok: Vec::new(),
2201 cse_mono_ok: Vec::new(),
2202 cse_vars: Vec::new(),
2203 cse_depth: Vec::new(),
2204 cse_depth_memo: std::collections::HashMap::new(),
2205 cse_index: std::collections::HashMap::new(),
2206 parse_depth: 0,
2207 max_depth: parse_depth_limit(),
2208 }
2209 }
2210
2211 /// Byte offset in the source text of the start of line `line`, or the
2212 /// end of the text when the cursor has run off it.
2213 ///
2214 /// Pointer arithmetic against the same allocation, never a deref: every
2215 /// line in `lines` borrows from the source buffer, so the difference is
2216 /// its offset.
2217 fn byte_at(&self, line: usize) -> usize {
2218 match self.lines.get(line) {
2219 Some(l) => l.as_ptr() as usize - self.txt_base,
2220 None => self.txt_len,
2221 }
2222 }
2223
2224 fn next_line(&mut self) -> Option<&'a str> {
2225 while self.pos < self.lines.len() {
2226 let l = self.lines[self.pos];
2227 self.pos += 1;
2228 // Strip comment after '#' for header / data lines (but
2229 // leave the segment-tag tokens untouched — they are the
2230 // first token on the line).
2231 let trimmed = strip_comment(l).trim();
2232 if !trimmed.is_empty() {
2233 return Some(l);
2234 }
2235 }
2236 None
2237 }
2238
2239 /// Next non-blank line, comment stripped and trimmed.
2240 ///
2241 /// Borrows from the source text rather than allocating: the result
2242 /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
2243 /// outlives the `&mut self` this took. A large `.nl` is mostly data
2244 /// lines — 620k of them for a 20k-variable model — and returning an
2245 /// owned `String` put one heap allocation on every single one.
2246 fn next_data_line(&mut self) -> Result<&'a str, String> {
2247 while self.pos < self.lines.len() {
2248 let l = self.lines[self.pos];
2249 self.pos += 1;
2250 let trimmed = strip_comment(l).trim();
2251 if !trimmed.is_empty() {
2252 return Ok(trimmed);
2253 }
2254 }
2255 Err("unexpected end of file in data line".to_string())
2256 }
2257
2258 fn parse_header(&mut self) -> Result<(), String> {
2259 let line0 = self.next_line().ok_or("empty .nl file")?;
2260 let trimmed = strip_comment(line0).trim();
2261 let first = trimmed.chars().next().ok_or("empty header line")?;
2262 if first != 'g' {
2263 return Err(format!(
2264 "only ASCII (g-) .nl files supported; got header '{trimmed}'"
2265 ));
2266 }
2267 // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
2268 // `g` say how many AMPL option words follow on the same line.
2269 // A solver echoes them back in the `.sol` `Options` block, so
2270 // keep them verbatim. Malformed or truncated option lists are not
2271 // fatal — the writer falls back to a generic block.
2272 let mut words = trimmed.split_whitespace();
2273 let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
2274 let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
2275 if opts.len() >= n_opts {
2276 self.ampl_options = opts[..n_opts].to_vec();
2277 }
2278
2279 // Header line 2: n_vars n_cons n_objs ranges eqns
2280 let l2 = self.next_data_line()?;
2281 let nums: Vec<&str> = l2.split_whitespace().collect();
2282 if nums.len() < 3 {
2283 return Err(format!("malformed line 2: '{l2}'"));
2284 }
2285 self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
2286 self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
2287 self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
2288
2289 // Header line 3: `nlc nlo`. Line 4: the network-constraint census,
2290 // which pounce has no use for. Line 5: `nlvc nlvo nlvb`. Together
2291 // these are the model's nonlinearity census — see [`NlCounts`].
2292 //
2293 // A header that does not carry them in the documented shape leaves
2294 // `nl_counts` at `None` rather than at a guess: every consumer has a
2295 // walk-the-trees fallback, and a fabricated count is worse than an
2296 // absent one. The rest of the header stays tolerant in the same way
2297 // the `nfunc` read below is.
2298 let l3 = self.next_data_line()?;
2299 let _l4_network = self.next_data_line()?;
2300 let l5 = self.next_data_line()?;
2301 self.nl_counts = parse_nl_counts(l3, l5);
2302 // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
2303 let l6 = self.next_data_line()?;
2304 let nums5: Vec<&str> = l6.split_whitespace().collect();
2305 self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
2306 // Lines 6..10 are metadata we mostly don't need. Line 7 is the
2307 // discrete-variable census; line 8 is `nzc nzo` — the Jacobian and
2308 // objective-gradient nonzero counts, kept because `nzc` is what
2309 // catches a file truncated before its `J` segments (gh#785); lines
2310 // 9 and 10 are the maximum name lengths and the common-expression
2311 // census.
2312 let l7_discrete = self.next_data_line()?;
2313 {
2314 let v: Vec<usize> = l7_discrete
2315 .split_whitespace()
2316 .map_while(|w| w.parse::<usize>().ok())
2317 .collect();
2318 if v.len() >= 2 {
2319 self.n_discrete = Some(DiscreteCensus {
2320 binary: v[0],
2321 integer: v[1],
2322 // `nlvbi nlvci nlvoi`; absent in a short header.
2323 nl_both: v.get(2).copied().unwrap_or(0),
2324 nl_cons: v.get(3).copied().unwrap_or(0),
2325 nl_objs: v.get(4).copied().unwrap_or(0),
2326 });
2327 }
2328 }
2329 let l8 = self.next_data_line()?;
2330 // Tolerant like the `nfunc` read above: a header that does not
2331 // carry the count in the documented shape leaves it `None`, and
2332 // the cross-check that reads it is skipped rather than fabricated.
2333 self.declared_jac_nnz = l8.split_whitespace().next().and_then(|s| s.parse().ok());
2334 let _l9_name_lens = self.next_data_line()?;
2335 let _l10_common_exprs = self.next_data_line()?;
2336 Ok(())
2337 }
2338
2339 fn peek_segment_line(&mut self) -> Option<&'a str> {
2340 let saved = self.pos;
2341 let l = self.next_line()?;
2342 self.pos = saved;
2343 Some(l)
2344 }
2345
2346 /// Eat the next non-blank line as a segment header. Returns the
2347 /// whole header (after stripping comments) and the comment text.
2348 fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
2349 let raw = self
2350 .next_line()
2351 .ok_or_else(|| "expected segment header".to_string())?;
2352 let (hdr, comment) = split_comment(raw);
2353 Ok((hdr.trim(), comment.trim()))
2354 }
2355
2356 /// Parse one `C`/`O` body, recognizing it from the token stream when
2357 /// that is possible instead of building its tree (gh #588, Q5).
2358 ///
2359 /// The cursor is the whole mechanism. `Parser` is line-based, so a
2360 /// failed recognition costs one assignment to rewind — the same trick
2361 /// `parse_funcall_arg` already uses to route a Hollerith literal — and
2362 /// the fallback then produces exactly the tree this file has always
2363 /// produced. Nothing downstream can tell which arm ran except by
2364 /// measuring memory.
2365 ///
2366 /// Only **degree-2** forms are kept. A body that recognizes as constant
2367 /// or linear is re-parsed as a tree and stored as one: the constant-row
2368 /// fold (gh #492), the linearity contract and the classifier's LP fast
2369 /// path all key on the identity zero and on trees, they are cheap
2370 /// already, and the memory that motivates this phase is entirely in
2371 /// degree-2 rows. Refusing to touch them keeps the change confined to
2372 /// the rows it is for.
2373 fn parse_body(&mut self) -> Result<NlBody, String> {
2374 if !self.quad_enabled {
2375 let e = self.parse_expr()?;
2376 self.check_effective_depth(&e)?;
2377 return Ok(NlBody::Tree(e));
2378 }
2379 let saved = self.pos;
2380 if let Some((form, vars, depth)) = self.parse_expr_quadratic() {
2381 if !form.quadratic().is_empty() {
2382 let src = self.byte_at(saved)..self.byte_at(self.pos);
2383 return Ok(NlBody::Quad(Box::new(QuadBody {
2384 form,
2385 vars,
2386 src,
2387 depth,
2388 })));
2389 }
2390 }
2391 self.pos = saved;
2392 let e = self.parse_expr()?;
2393 self.check_effective_depth(&e)?;
2394 Ok(NlBody::Tree(e))
2395 }
2396
2397 /// gh#986 review: refuse a tree whose depth *through the defined
2398 /// variables it references* exceeds the guard. `parse_expr` counts only
2399 /// the syntactic nesting of the text it reads, and a `v<i>` reference to a
2400 /// `V` segment is one token there; a chain of `V` segments each referencing
2401 /// the previous is shallow to the parser and as deep as the chain to every
2402 /// pass that walks through a `Cse` (the tape builder, `collect_vars`, drop
2403 /// glue), so the stack the guard exists to protect was unguarded.
2404 fn check_effective_depth(&self, e: &Expr) -> Result<u32, String> {
2405 let d = expr_tree_depth(e, &self.cse_depth_memo);
2406 if d > self.max_depth {
2407 return Err(format!(
2408 "the model nests an expression {d} levels deep counting the defined \
2409 variables (V segments) it references, more than the {} levels the \
2410 recursive reader accepts; a chain of defined variables each \
2411 referencing the previous counts once per link",
2412 self.max_depth
2413 ));
2414 }
2415 Ok(d)
2416 }
2417
2418 /// Read one expression off the token stream as a degree-≤2 form,
2419 /// **without building it**, or give up.
2420 ///
2421 /// This is the same computation as
2422 /// `is_expanded_quadratic(e) && recognize_expr(e)` run on the tree these
2423 /// tokens parse to — the accuracy gate and the algebra, interleaved so
2424 /// neither needs the tree. That equivalence is the phase's correctness
2425 /// claim and is asserted directly, bit for bit, over every body of every
2426 /// `.nl` file in the repository by
2427 /// `pounce-cli/tests/quad_parse_differential.rs`.
2428 ///
2429 /// Two things it must reproduce and not merely approximate:
2430 ///
2431 /// * **The exactness rule.** `is_expanded_quadratic` admits a body only
2432 /// when reading it as `½xᵀHx + aᵀx + c` repeats the additions the
2433 /// writer already wrote — expanding a *factored* form cancels, which
2434 /// is what took `airport.nl` from 16 to 300 iterations in Q4. Here
2435 /// that rule is structural rather than a separate pass: `+`/`-` are
2436 /// the spine, everything under a `*`, `/` or `^` is read in monomial
2437 /// mode, and a `+` seen in monomial mode gives up.
2438 /// * **The association.** `recognize_expr` folds an `o54` sumlist from
2439 /// the *first* operand to the last, which is also the order the AD
2440 /// tape sums in, so this folds the same way — operands arrive here in
2441 /// file order and are folded as they arrive. Summation order is not
2442 /// observable on distinct monomials and is exactly observable on
2443 /// repeated ones.
2444 ///
2445 /// On `None` the cursor is left wherever it stopped; the caller rewinds.
2446 fn parse_expr_quadratic(&mut self) -> Option<(Quad2, Vec<u32>, u32)> {
2447 let mut frames: Vec<QFrame> = Vec::new();
2448 let mut vals: Vec<Quad2> = Vec::new();
2449 let mut vars: Vec<u32> = Vec::new();
2450 // Deepest node of the tree these tokens describe. A leaf sits under
2451 // `frames.len()` operators, and a CSE reference carries its body's
2452 // depth under it — the same convention `pounce-py`'s `expr_depth`
2453 // uses, because that is who reads the answer.
2454 let mut depth: u32 = 0;
2455 let note_leaf = |frames: &[QFrame], depth: &mut u32, below: u32| {
2456 let d = u32::try_from(frames.len()).unwrap_or(u32::MAX);
2457 *depth = (*depth).max(d.saturating_add(1).saturating_add(below));
2458 };
2459
2460 loop {
2461 let mono = frames.last().is_some_and(|f| f.mono);
2462 // A `^` base must be atomic — `Const`, `Var` or a `Neg` — or the
2463 // body is a factored form dressed as a monomial. `is_monomial`
2464 // applies the same test to `Pow`'s left operand.
2465 let pow_base = matches!(
2466 frames.last(),
2467 Some(QFrame {
2468 op: QOp::Pow,
2469 remaining: 2,
2470 ..
2471 }) | Some(QFrame {
2472 op: QOp::Square,
2473 remaining: 1,
2474 ..
2475 })
2476 );
2477
2478 let raw = self.next_line()?;
2479 let tok = strip_comment(raw).trim();
2480 let first = tok.chars().next()?;
2481 match first {
2482 'n' => {
2483 // A constant base is atomic; `is_monomial` accepts it.
2484 // A non-finite literal bails out of the fast path so the
2485 // general parser reaches it and reports the error rather
2486 // than folding it into a quadratic (gh #847).
2487 let v: Number = tok[1..]
2488 .trim()
2489 .parse()
2490 .ok()
2491 .filter(|v: &Number| v.is_finite())?;
2492 note_leaf(&frames, &mut depth, 0);
2493 vals.push(Quad2::of_constant(v));
2494 }
2495 'v' => {
2496 let i: usize = tok[1..].trim().parse().ok()?;
2497 if i < self.n {
2498 note_leaf(&frames, &mut depth, 0);
2499 vars.push(u32::try_from(i).ok()?);
2500 vals.push(Quad2::of_var(i));
2501 } else {
2502 // A CSE reference lowers to `Expr::Cse`, which is
2503 // *not* one of the shapes `is_monomial` accepts as a
2504 // power's base.
2505 if pow_base {
2506 return None;
2507 }
2508 let local = i.checked_sub(self.n)?;
2509 let ok = if mono {
2510 *self.cse_mono_ok.get(local)?
2511 } else {
2512 *self.cse_sum_ok.get(local)?
2513 };
2514 if !ok {
2515 return None;
2516 }
2517 let form = self.cse_quad.get(local)?.clone()?;
2518 note_leaf(&frames, &mut depth, *self.cse_depth.get(local)?);
2519 vars.extend_from_slice(self.cse_vars.get(local)?);
2520 vals.push(form);
2521 }
2522 }
2523 'o' => {
2524 let code: i32 = tok[1..].trim().parse().ok()?;
2525 if pow_base && code != 16 {
2526 return None;
2527 }
2528 let (op, arity, child_mono) = match code {
2529 // The sum spine. Inside a monomial there is no such
2530 // thing, and the body is a factored form.
2531 0 if !mono => (QOp::Add, 2, false),
2532 1 if !mono => (QOp::Sub, 2, false),
2533 16 => (QOp::Neg, 1, mono),
2534 54 if !mono => {
2535 let count_line = self.next_data_line().ok()?;
2536 let count: usize =
2537 count_line.split_whitespace().next()?.parse().ok()?;
2538 (QOp::Sum(count), count, false)
2539 }
2540 // Monomial operators: everything below them is read
2541 // in monomial mode.
2542 2 => (QOp::Mul, 2, true),
2543 3 => (QOp::Div, 2, true),
2544 5 | 81 | 83 => (QOp::Pow, 2, true),
2545 82 => (QOp::Square, 1, true),
2546 // Transcendentals, comparisons, conditionals,
2547 // min/max lists, and `+`/`-` under a monomial.
2548 _ => return None,
2549 };
2550 if arity == 0 {
2551 // An empty `o54` is the empty sum: zero.
2552 note_leaf(&frames, &mut depth, 0);
2553 vals.push(Quad2::default());
2554 } else {
2555 frames.push(QFrame {
2556 op,
2557 remaining: arity,
2558 mono: child_mono,
2559 });
2560 continue;
2561 }
2562 }
2563 // `f` (imported function call), `h`, and anything else.
2564 _ => return None,
2565 }
2566
2567 // A value has just been produced. Close every frame it completes.
2568 while let Some(f) = frames.last_mut() {
2569 f.remaining -= 1;
2570 if f.remaining > 0 {
2571 break;
2572 }
2573 let f = frames.pop()?;
2574 let combined = apply_quad_op(f.op, &mut vals)?;
2575 vals.push(combined);
2576 }
2577 if frames.is_empty() {
2578 break;
2579 }
2580 }
2581
2582 if vals.len() != 1 {
2583 return None;
2584 }
2585 vars.sort_unstable();
2586 vars.dedup();
2587 Some((vals.pop()?, vars, depth))
2588 }
2589
2590 fn parse_expr(&mut self) -> Result<Expr, String> {
2591 // gh#986: a recursion guard that fires before the stack does. A
2592 // stack overflow is an abort, not an error return, so the reader
2593 // refuses past `MAX_PARSE_DEPTH` levels with a clean message at any
2594 // input depth instead of relying on the caller's stack size.
2595 if self.parse_depth >= self.max_depth {
2596 return Err(format!(
2597 "the model nests an expression more than {} levels deep, which the \
2598 recursive reader refuses rather than overflow the stack (a long \
2599 left-deep `o0` sum is read flat and does not count; deep unary or \
2600 `o2`/`o3` nesting does)",
2601 self.max_depth
2602 ));
2603 }
2604 self.parse_depth += 1;
2605 let r = self.parse_expr_inner();
2606 self.parse_depth -= 1;
2607 r
2608 }
2609
2610 fn parse_expr_inner(&mut self) -> Result<Expr, String> {
2611 let raw = self
2612 .next_line()
2613 .ok_or_else(|| "expected expression token".to_string())?;
2614 // Borrowed, not owned: this runs once per node of every
2615 // expression tree in the file, so an owned `String` here is one
2616 // heap allocation per tape op in the whole model.
2617 let tok = strip_comment(raw).trim();
2618 if tok.is_empty() {
2619 return Err("empty expression token".into());
2620 }
2621 let first = tok.chars().next().ok_or("empty expression token")?;
2622 match first {
2623 'n' => {
2624 let v: Number = tok[1..]
2625 .trim()
2626 .parse()
2627 .map_err(|e| format!("n value: {e}"))?;
2628 // A `nan` literal in the objective body reached the answer
2629 // itself: `Objective: nan` under `Solve_Succeeded` and exit
2630 // code 0 (gh #847).
2631 Ok(Expr::Const(finite_or_err("numeric literal", v)?))
2632 }
2633 'v' => {
2634 let i: usize = tok[1..]
2635 .trim()
2636 .parse()
2637 .map_err(|e| format!("v index: {e}"))?;
2638 Ok(self.var_or_cse(i)?)
2639 }
2640 'o' => {
2641 let code: i32 = tok[1..]
2642 .trim()
2643 .parse()
2644 .map_err(|e| format!("opcode: {e}"))?;
2645 self.parse_opcode(code)
2646 }
2647 'f' => {
2648 // AMPL imported (external) function call: `f<id> <nargs>`
2649 // followed by nargs child expressions (or string literals).
2650 let rest = &tok[1..];
2651 let mut parts = rest.split_whitespace();
2652 let id_str = parts
2653 .next()
2654 .ok_or_else(|| format!("missing function id in '{tok}'"))?;
2655 let nargs_str = parts
2656 .next()
2657 .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
2658 let id: usize = id_str
2659 .parse()
2660 .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
2661 let nargs: usize = nargs_str
2662 .parse()
2663 .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
2664 let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
2665 for _ in 0..nargs {
2666 args.push(self.parse_funcall_arg()?);
2667 }
2668 Ok(Expr::Funcall { id, args })
2669 }
2670 't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
2671 other => Err(format!(
2672 "unexpected expression token start '{other}': '{tok}'"
2673 )),
2674 }
2675 }
2676
2677 /// Parse one argument to an AMPL imported function. An argument
2678 /// is either a normal expression (real-valued) or a string literal
2679 /// in the form `h<len>:<chars>`. AMPL emits string args only when the
2680 /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
2681 /// or a parameters-directory path for IDAES Helmholtz functions).
2682 fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
2683 // Peek the next non-blank line so we can route `h...` differently.
2684 let saved = self.pos;
2685 let raw = self
2686 .next_line()
2687 .ok_or_else(|| "expected funcall argument".to_string())?;
2688 // A string arg is a Hollerith literal `h<len>:<chars>` where the
2689 // chars are *exactly* `<len>` bytes and may legitimately contain
2690 // '#'. We must NOT strip a trailing comment before extracting the
2691 // content (that would truncate e.g. a path like `a#b`), and we
2692 // honor the declared length rather than splitting loosely on ':'.
2693 // Detect the form from the leading non-blank char of the raw line;
2694 // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
2695 let lead = raw.trim_start();
2696 if let Some(after_h) = lead.strip_prefix('h') {
2697 let colon = after_h
2698 .find(':')
2699 .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
2700 let len: usize = after_h[..colon]
2701 .trim()
2702 .parse()
2703 .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
2704 let chars = &after_h[colon + 1..];
2705 if chars.len() < len {
2706 return Err(format!(
2707 "Hollerith string shorter than declared length {len}: {chars:?}"
2708 ));
2709 }
2710 // Take exactly `len` bytes; anything past it (trailing
2711 // whitespace, a real comment) is not part of the string.
2712 if !chars.is_char_boundary(len) {
2713 return Err(format!(
2714 "Hollerith length {len} splits a multibyte char in {chars:?}"
2715 ));
2716 }
2717 Ok(FuncallArg::Str(chars[..len].to_string()))
2718 } else {
2719 // Rewind: parse_expr re-consumes the line we just peeked.
2720 self.pos = saved;
2721 Ok(FuncallArg::Real(self.parse_expr()?))
2722 }
2723 }
2724
2725 fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
2726 match code {
2727 0 => {
2728 // gh#986: a left-deep run of binary `o0` — `o0 o0 o0 … a b c d`,
2729 // what AMPL and discopt write for a long sum — is read as one
2730 // n-ary `Sum`, so the tree is one level deep however many terms
2731 // there are. Taken only past `SUM_CHAIN_FLATTEN_MIN` links, so
2732 // an ordinary model keeps exactly the tree it always had.
2733 // Each `o0`'s first child is the next line, so the `k + 1`
2734 // operands that follow the run are, in order, the leaves of
2735 // `((a + b) + c) + d`; `Sum` adds them left to right, the same
2736 // order of operations.
2737 let after_first = self.pos;
2738 let mut k = 1usize;
2739 loop {
2740 let before = self.pos;
2741 match self.next_line() {
2742 Some(l) if strip_comment(l).trim() == "o0" => k += 1,
2743 _ => {
2744 self.pos = before;
2745 break;
2746 }
2747 }
2748 }
2749 if k > SUM_CHAIN_FLATTEN_MIN {
2750 let mut terms = Vec::with_capacity(k + 1);
2751 for _ in 0..=k {
2752 terms.push(self.parse_expr()?);
2753 }
2754 return Ok(Expr::Sum(terms));
2755 }
2756 self.pos = after_first;
2757 let a = self.parse_expr()?;
2758 let b = self.parse_expr()?;
2759 Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
2760 }
2761 1 => {
2762 let a = self.parse_expr()?;
2763 let b = self.parse_expr()?;
2764 Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
2765 }
2766 2 => {
2767 let a = self.parse_expr()?;
2768 let b = self.parse_expr()?;
2769 Ok(merge_abs_pow_product(a, b))
2770 }
2771 3 => {
2772 let a = self.parse_expr()?;
2773 let b = self.parse_expr()?;
2774 Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
2775 }
2776 5 => {
2777 let a = self.parse_expr()?;
2778 let b = self.parse_expr()?;
2779 Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
2780 }
2781 15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
2782 16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
2783 39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
2784 41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
2785 42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
2786 43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
2787 44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
2788 46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
2789 38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
2790 49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
2791 53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
2792 40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
2793 45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
2794 37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
2795 51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
2796 52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
2797 50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
2798 47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
2799 // atan2(y, x): binary, operand order `y` then `x`.
2800 48 => {
2801 let a = self.parse_expr()?;
2802 let b = self.parse_expr()?;
2803 Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
2804 }
2805 // Relational comparisons (binary). Operand order is
2806 // `left OP right`.
2807 22 => self.parse_compare(CmpOp::Lt),
2808 23 => self.parse_compare(CmpOp::Le),
2809 24 => self.parse_compare(CmpOp::Eq),
2810 28 => self.parse_compare(CmpOp::Ge),
2811 29 => self.parse_compare(CmpOp::Gt),
2812 30 => self.parse_compare(CmpOp::Ne),
2813 // Logical connectives.
2814 20 => {
2815 let a = self.parse_expr()?;
2816 let b = self.parse_expr()?;
2817 Ok(Expr::Or(Box::new(a), Box::new(b)))
2818 }
2819 21 => {
2820 let a = self.parse_expr()?;
2821 let b = self.parse_expr()?;
2822 Ok(Expr::And(Box::new(a), Box::new(b)))
2823 }
2824 34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
2825 // if-then-else: condition, then-value, else-value.
2826 35 => {
2827 let cond = self.parse_expr()?;
2828 let then_ = self.parse_expr()?;
2829 let else_ = self.parse_expr()?;
2830 Ok(Expr::Cond {
2831 cond: Box::new(cond),
2832 then_: Box::new(then_),
2833 else_: Box::new(else_),
2834 })
2835 }
2836 54 => {
2837 // Variadic sum: next data line gives the count.
2838 let count_line = self.next_data_line()?;
2839 let count: usize = count_line
2840 .split_whitespace()
2841 .next()
2842 .ok_or_else(|| "missing variadic count".to_string())?
2843 .parse()
2844 .map_err(|e| format!("variadic count: {e}"))?;
2845 let mut args = Vec::with_capacity(count);
2846 for _ in 0..count {
2847 args.push(self.parse_expr()?);
2848 }
2849 Ok(Expr::Sum(args))
2850 }
2851 // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
2852 // a count data line followed by that many operands.
2853 11 | 12 => {
2854 let count_line = self.next_data_line()?;
2855 let count: usize = count_line
2856 .split_whitespace()
2857 .next()
2858 .ok_or_else(|| "missing min/max list count".to_string())?
2859 .parse()
2860 .map_err(|e| format!("min/max list count: {e}"))?;
2861 let mut args = Vec::with_capacity(count);
2862 for _ in 0..count {
2863 args.push(self.parse_expr()?);
2864 }
2865 if code == 11 {
2866 Ok(Expr::MinList(args))
2867 } else {
2868 Ok(Expr::MaxList(args))
2869 }
2870 }
2871 // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
2872 // emits these in place of the general `o5` (OPPOW) as a hint that
2873 // one operand is constant. The distinction exists because an
2874 // integer / half-integer constant power is evaluated by a
2875 // mul/sqrt chain that stays real for a negative base, whereas the
2876 // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
2877 // they read exactly like `o5`, so they lower to the same `Pow` AST
2878 // and reuse the existing constant-power tape lowering (see
2879 // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
2880 // against the ASL reader and the `ampl/mp` opcode table:
2881 // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
2882 // unary with an implicit exponent of 2.
2883 //
2884 // o81 OP1POW: `base ^ (const exponent)` — binary, operands
2885 // `base` then `exp` (the exponent is a numeric node here).
2886 81 => {
2887 let base = self.parse_expr()?;
2888 let exp = self.parse_expr()?;
2889 Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2890 }
2891 // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
2892 82 => {
2893 let base = self.parse_expr()?;
2894 Ok(Expr::Binary(
2895 BinOp::Pow,
2896 Box::new(base),
2897 Box::new(Expr::Const(2.0)),
2898 ))
2899 }
2900 // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
2901 // (the numeric node) then `exp`.
2902 83 => {
2903 let base = self.parse_expr()?;
2904 let exp = self.parse_expr()?;
2905 Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2906 }
2907 other => Err(format!("unsupported opcode o{other}")),
2908 }
2909 }
2910
2911 /// Parse the two operands of a relational opcode into an
2912 /// [`Expr::Compare`]. Operand order is `left OP right`.
2913 fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
2914 let a = self.parse_expr()?;
2915 let b = self.parse_expr()?;
2916 Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
2917 }
2918
2919 /// Resolve a `v<i>` token into either a plain variable reference
2920 /// (`i < n`) or a shared CSE reference (`i >= n`).
2921 fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
2922 if i < self.n {
2923 Ok(Expr::Var(i))
2924 } else {
2925 let local = i - self.n;
2926 self.cses
2927 .get(local)
2928 .map(|rc| Expr::Cse(rc.clone()))
2929 .ok_or_else(|| {
2930 format!(
2931 "v{i} references CSE {local} but only {} have been defined",
2932 self.cses.len()
2933 )
2934 })
2935 }
2936 }
2937
2938 /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
2939 /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
2940 /// CSEs are numbered starting at `n` and must appear in order.
2941 fn parse_v_segment(&mut self) -> Result<(), String> {
2942 let (hdr, _) = self.eat_segment_header()?;
2943 let parts: Vec<&str> = hdr.split_whitespace().collect();
2944 if parts.len() < 2 {
2945 return Err(format!("malformed V-segment header: {hdr}"));
2946 }
2947 let cse_idx = parse_segment_index(parts[0], 'V')?;
2948 let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
2949 // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
2950 let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
2951 for _ in 0..nlin {
2952 let line = self.next_data_line()?;
2953 let (var, coef) = parse_var_coef(line)?;
2954 linear.push((var, coef));
2955 }
2956 let nonlin = self.parse_expr()?;
2957 // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
2958 // reference earlier CSEs as well as plain variables.
2959 let mut combined = nonlin;
2960 for (var, coef) in linear {
2961 let v_expr = self.var_or_cse(var)?;
2962 let term = if coef == 1.0 {
2963 v_expr
2964 } else {
2965 Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
2966 };
2967 combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
2968 }
2969 if cse_idx < self.n {
2970 return Err(format!("V{cse_idx} below n={}", self.n));
2971 }
2972 let local = cse_idx - self.n;
2973 if local != self.cses.len() {
2974 return Err(format!(
2975 "V-segment index V{cse_idx} out of order; expected V{}",
2976 self.n + self.cses.len()
2977 ));
2978 }
2979 // What a reference to this body would mean to the streaming
2980 // recognizer, answered once here rather than per reference. The
2981 // `V` tree exists at this point, so these are the *same* functions
2982 // `NlTnlp` applies to a whole row — the parse-time recognizer never
2983 // gets a second opinion about a CSE.
2984 // gh#986 review: bound the chain before any pass walks through it.
2985 let depth = self.check_effective_depth(&combined)?;
2986 if self.quad_enabled {
2987 // gh#986 review: every earlier body's verdicts are already
2988 // known, so each pass reads them instead of re-walking the chain
2989 // of defined variables below this one (quadratic over a long
2990 // chain otherwise).
2991 let idx = &self.cse_index;
2992 let (quad, sum_ok, mono_ok) = (&self.cse_quad, &self.cse_sum_ok, &self.cse_mono_ok);
2993 let quad_seed = |k: *const Expr| idx.get(&k).map(|&i| quad[i].clone());
2994 let shape_seed = |k: *const Expr, mono: bool| {
2995 idx.get(&k)
2996 .map(|&i| if mono { mono_ok[i] } else { sum_ok[i] })
2997 };
2998 let q = crate::nl_quadratic::recognize_expr_seeded(&combined, &quad_seed);
2999 let s_ok = crate::nl_quadratic::is_expanded_quadratic_seeded(&combined, &shape_seed);
3000 let m_ok = crate::nl_quadratic::is_monomial_expr_seeded(&combined, &shape_seed);
3001 let mut vars: BTreeSet<usize> = BTreeSet::new();
3002 collect_vars_seeded(&combined, &mut vars, &self.cse_index, &self.cse_vars);
3003 self.cse_quad.push(q);
3004 self.cse_sum_ok.push(s_ok);
3005 self.cse_mono_ok.push(m_ok);
3006 self.cse_vars
3007 .push(vars.into_iter().map(|v| v as u32).collect());
3008 self.cse_depth.push(depth);
3009 }
3010 let body = Arc::new(combined);
3011 self.cse_index.insert(Arc::as_ptr(&body), self.cses.len());
3012 self.cse_depth_memo.insert(Arc::as_ptr(&body), depth);
3013 self.cses.push(body);
3014 Ok(())
3015 }
3016}
3017
3018/// Combine the operands of one recognized operator, mirroring
3019/// [`crate::nl_quadratic::recognize_expr`]'s `Apply` arm term for term.
3020///
3021/// Every difference from that function is a difference in the coefficients
3022/// this parser stores, so there is nothing here that is "equivalent but
3023/// tidier": the division scales by the reciprocal because that is what the
3024/// tree walk does, and the sumlist folds back to front for the same reason.
3025/// Nesting depth of a `V`-segment body, leaf = 1, a `Cse` reference
3026/// counting one level above its body — the convention `pounce-py`'s
3027/// `expr_depth` uses, since that is the guard the answer feeds.
3028///
3029/// Recursive, and safe to be: it recurses only through the syntax of `e`,
3030/// which [`Parser::parse_expr`] has just built *recursively* on this same
3031/// stack, so a frame that fits the parser fits this. A `Cse` reference is
3032/// read from `memo` (every `V` body is memoized when its segment is parsed,
3033/// and a body can only reference earlier ones), so the walk never descends a
3034/// chain of defined variables: linear in `e`, where it used to be linear in
3035/// the whole chain on every `V` segment -- quadratic over a long chain, and
3036/// as deep as it (gh#986 review).
3037fn expr_tree_depth(e: &Expr, memo: &std::collections::HashMap<*const Expr, u32>) -> u32 {
3038 let deepest = |kids: &mut dyn Iterator<Item = &Expr>| {
3039 kids.fold(0u32, |acc, k| acc.max(expr_tree_depth(k, memo)))
3040 };
3041 1 + match e {
3042 Expr::Const(_) | Expr::Var(_) => 0,
3043 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3044 deepest(&mut [&**a, &**b].into_iter())
3045 }
3046 Expr::Unary(_, a) | Expr::Not(a) => expr_tree_depth(a, memo),
3047 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => deepest(&mut args.iter()),
3048 Expr::Cond { cond, then_, else_ } => {
3049 deepest(&mut [&**cond, &**then_, &**else_].into_iter())
3050 }
3051 Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
3052 FuncallArg::Real(inner) => Some(inner),
3053 FuncallArg::Str(_) => None,
3054 })),
3055 Expr::Cse(body) => match memo.get(&Arc::as_ptr(body)) {
3056 Some(&d) => d,
3057 None => expr_tree_depth(body, memo),
3058 },
3059 }
3060}
3061
3062fn apply_quad_op(op: QOp, vals: &mut Vec<Quad2>) -> Option<Quad2> {
3063 let pop2 = |vals: &mut Vec<Quad2>| -> Option<(Quad2, Quad2)> {
3064 let b = vals.pop()?;
3065 let a = vals.pop()?;
3066 Some((a, b))
3067 };
3068 Some(match op {
3069 QOp::Sum(n) => {
3070 let at = vals.len().checked_sub(n)?;
3071 let mut acc = Quad2::default();
3072 // Operands arrive in file order, so `drain` already yields them
3073 // front to back — the order `recognize_expr` folds in, and the
3074 // order the AD tape sums in. Do not reverse this: floating-point
3075 // addition is not associative, and on repeated monomials the two
3076 // orders do not agree bit for bit.
3077 for p in vals.drain(at..) {
3078 acc = Quad2::add(acc, p);
3079 }
3080 acc
3081 }
3082 QOp::Neg => vals.pop()?.neg(),
3083 QOp::Add => {
3084 let (a, b) = pop2(vals)?;
3085 Quad2::add(a, b)
3086 }
3087 QOp::Sub => {
3088 let (a, b) = pop2(vals)?;
3089 Quad2::add(a, b.neg())
3090 }
3091 QOp::Mul => {
3092 let (a, b) = pop2(vals)?;
3093 a.mul(&b)?
3094 }
3095 QOp::Div => {
3096 let (a, b) = pop2(vals)?;
3097 let d = b.as_constant()?;
3098 if d == 0.0 {
3099 return None;
3100 }
3101 let mut out = a.div_by_constant(d);
3102 out.absorb_flags(&b);
3103 out
3104 }
3105 QOp::Pow => {
3106 let (a, b) = pop2(vals)?;
3107 let exp = b.as_constant()?;
3108 let mut out = if exp == 0.0 {
3109 Quad2::of_constant(1.0)
3110 } else if exp == 1.0 {
3111 a
3112 } else if exp == 2.0 {
3113 a.mul(&a)?
3114 } else {
3115 return None;
3116 };
3117 // The exponent is read out of a form with `as_constant`, which
3118 // leaves its flags behind; `Div` above does the same.
3119 out.absorb_flags(&b);
3120 out
3121 }
3122 // `o82` is `Pow(base, Const(2.0))` with the exponent left implicit,
3123 // so it takes the `exp == 2.0` branch above.
3124 QOp::Square => {
3125 let a = vals.pop()?;
3126 a.mul(&a)?
3127 }
3128 })
3129}
3130
3131/// Re-parse one recognized body from the bytes it was recognized from.
3132///
3133/// The point is that this is the *same* function the original parse ran, on
3134/// the *same* bytes, with the same `n` and the same `V`-segment bodies — so
3135/// the tree it returns is the tree that parse would have built, down to the
3136/// `Arc` identities that `HybridTape::build_multi` keys CSE sharing on.
3137/// Deriving an equivalent tree from the stored coefficients instead would
3138/// be a different tree, evaluated by a different tape, and this phase would
3139/// stop being invisible from outside.
3140fn parse_body_fragment(txt: &str, n: usize, cses: &[Arc<Expr>]) -> Result<Expr, String> {
3141 let mut p = Parser::new(txt, false);
3142 p.n = n;
3143 p.cses = cses.to_vec();
3144 p.parse_expr()
3145}
3146
3147/// `a·b` for an `o2` node, with one rewrite: when a factor `|e|^p` on the
3148/// left meets a factor `|e|^q` on the right (same operand `e`, constant
3149/// `p, q > 0`), the two become the single factor `|e|^(p+q)` (gh#1042 R3).
3150///
3151/// **Why.** Vanderbei's `dallas*` models write a head-loss term as
3152/// `(k·|x|^2)·|x|^0.85`. Differentiated as written, the product rule meets
3153/// `|x|^0.85` at `x = 0`, where its slope `0.85·|x|^-0.15` is `∞` and its
3154/// second derivative `-0.1275·|x|^-1.15` is `-∞`, against co-factors
3155/// `k·|x|^2 = 0` and `2k·|x| = 0`: the Hessian of the term is `0·∞ = NaN`
3156/// (the gradient survives, measured `0`), though the term is `k·|x|^2.85`,
3157/// whose second derivative at `0` is `0`. The NaN Hessian fails the KKT
3158/// factorization and forces a restoration entry each time a flow lands on
3159/// exactly zero — 11 of `dallass`'s 12 restoration calls on 0.13.0. Merged,
3160/// the tape differentiates `|x|^2.85` directly and every derivative at the
3161/// kink is the finite, correct one.
3162///
3163/// **Why it is exact.** `|e| ≥ 0`, so `|e|^p·|e|^q = |e|^(p+q)` holds for
3164/// every real `e` and every `p, q > 0` — including `e = 0` (`0·0 = 0`),
3165/// `|e| = ∞` (`∞·∞ = ∞`) and `e = NaN` (NaN either way); the two sides
3166/// differ by rounding only. The restrictions are what keep it exact:
3167///
3168/// * the base must be `abs(·)` — for a bare `e < 0` and non-integer
3169/// powers `e^0.5·e^0.5` is `NaN·NaN` while `e^1` is `e`;
3170/// * both exponents must be positive literals — `|e|^-1·|e|^2` is
3171/// `∞·0 = NaN` at `e = 0` but `|e|^1 = 0`, so a negative or zero power
3172/// would change a value, not only a derivative;
3173/// * the operands must be the *same* expression (same variable, same
3174/// shared `Cse` body, or structurally identical) — see [`same_operand`].
3175///
3176/// Away from `e = 0` the derivatives are unchanged mathematically:
3177/// `d/de |e|^(p+q)` and the product rule on `|e|^p·|e|^q` are the same
3178/// function there. Only the evaluation at the kink changes, from `NaN` to
3179/// the limit.
3180///
3181/// **Scope.** Only the two operands of this `o2` and, at most, one `Mul`
3182/// level inside each are inspected, which is what an AMPL left-associated
3183/// `k*abs(x)^2*abs(x)^0.85` produces; a pair inside one operand was already
3184/// merged when that operand was read. When nothing matches, the node is
3185/// built exactly as before (`Mul(a, b)`), so a model without the pattern
3186/// gets an identical tree and tape.
3187fn merge_abs_pow_product(a: Expr, b: Expr) -> Expr {
3188 fn factors(e: &Expr) -> Vec<&Expr> {
3189 match e {
3190 Expr::Binary(BinOp::Mul, x, y) => vec![&**x, &**y],
3191 other => vec![other],
3192 }
3193 }
3194 fn into_factors(e: Expr) -> Vec<Expr> {
3195 match e {
3196 Expr::Binary(BinOp::Mul, x, y) => vec![*x, *y],
3197 other => vec![other],
3198 }
3199 }
3200 let hit = {
3201 let (fa, fb) = (factors(&a), factors(&b));
3202 let mut hit = None;
3203 'outer: for (i, x) in fa.iter().enumerate() {
3204 let Some((ex, px)) = abs_pow_parts(x) else {
3205 continue;
3206 };
3207 for (j, y) in fb.iter().enumerate() {
3208 if let Some((ey, py)) = abs_pow_parts(y)
3209 && (px + py).is_finite()
3210 && same_operand(ex, ey)
3211 {
3212 hit = Some((i, j, px + py));
3213 break 'outer;
3214 }
3215 }
3216 }
3217 hit
3218 };
3219 let Some((i, j, p)) = hit else {
3220 return Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b));
3221 };
3222 let mut fa = into_factors(a);
3223 let mut fb = into_factors(b);
3224 fb.remove(j);
3225 let Expr::Binary(BinOp::Pow, base, _) = std::mem::replace(&mut fa[i], Expr::Const(0.0)) else {
3226 unreachable!("abs_pow_parts matched a Pow node");
3227 };
3228 fa[i] = Expr::Binary(BinOp::Pow, base, Box::new(Expr::Const(p)));
3229 let mut it = fa.into_iter().chain(fb);
3230 let first = it.next().expect("at least the merged factor");
3231 it.fold(first, |acc, f| {
3232 Expr::Binary(BinOp::Mul, Box::new(acc), Box::new(f))
3233 })
3234}
3235
3236/// `Some((e, p))` when `x` is `|e|^p` with a finite literal `p > 0`.
3237fn abs_pow_parts(x: &Expr) -> Option<(&Expr, f64)> {
3238 match x {
3239 Expr::Binary(BinOp::Pow, base, exp) => match (&**base, &**exp) {
3240 (Expr::Unary(UnaryOp::Abs, e), Expr::Const(p)) if p.is_finite() && *p > 0.0 => {
3241 Some((&**e, *p))
3242 }
3243 _ => None,
3244 },
3245 _ => None,
3246 }
3247}
3248
3249/// Conservative structural identity for [`merge_abs_pow_product`]: `true`
3250/// only when `x` and `y` are certainly the same function of the variables —
3251/// the same variable, the same shared `Cse` body (pointer identity), a
3252/// bit-identical constant, or the same unary/binary operator over operands
3253/// that are themselves identical. Anything else (sums, calls, logic,
3254/// conditionals) answers `false`, which only ever declines a merge.
3255fn same_operand(x: &Expr, y: &Expr) -> bool {
3256 match (x, y) {
3257 (Expr::Var(i), Expr::Var(j)) => i == j,
3258 (Expr::Const(a), Expr::Const(b)) => a.to_bits() == b.to_bits(),
3259 (Expr::Cse(a), Expr::Cse(b)) => Arc::ptr_eq(a, b),
3260 (Expr::Unary(oa, a), Expr::Unary(ob, b)) => oa == ob && same_operand(a, b),
3261 (Expr::Binary(oa, a1, a2), Expr::Binary(ob, b1, b2)) => {
3262 oa == ob && same_operand(a1, b1) && same_operand(a2, b2)
3263 }
3264 _ => false,
3265 }
3266}
3267
3268fn strip_comment(s: &str) -> &str {
3269 match s.find('#') {
3270 Some(i) => &s[..i],
3271 None => s,
3272 }
3273}
3274
3275fn split_comment(s: &str) -> (&str, &str) {
3276 match s.find('#') {
3277 Some(i) => (&s[..i], &s[i + 1..]),
3278 None => (s, ""),
3279 }
3280}
3281
3282// --------------------------------------------------------------------
3283// Expression evaluation and gradient (tree walkers, kept for tests).
3284// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
3285// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
3286// --------------------------------------------------------------------
3287
3288/// Forward-mode value evaluation.
3289pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
3290 match e {
3291 Expr::Const(c) => *c,
3292 Expr::Var(i) => x[*i],
3293 Expr::Binary(op, a, b) => {
3294 let va = eval_expr(a, x);
3295 let vb = eval_expr(b, x);
3296 match op {
3297 BinOp::Add => va + vb,
3298 BinOp::Sub => va - vb,
3299 BinOp::Mul => va * vb,
3300 BinOp::Div => va / vb,
3301 BinOp::Pow => va.powf(vb),
3302 BinOp::Atan2 => va.atan2(vb),
3303 BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
3304 }
3305 }
3306 Expr::Unary(op, a) => {
3307 let va = eval_expr(a, x);
3308 match op {
3309 UnaryOp::Neg => -va,
3310 UnaryOp::Sqrt => va.sqrt(),
3311 UnaryOp::Log => va.ln(),
3312 UnaryOp::Log10 => va.log10(),
3313 UnaryOp::Exp => va.exp(),
3314 UnaryOp::Abs => va.abs(),
3315 UnaryOp::Sin => va.sin(),
3316 UnaryOp::Cos => va.cos(),
3317 UnaryOp::Tan => va.tan(),
3318 UnaryOp::Atan => va.atan(),
3319 UnaryOp::Acos => va.acos(),
3320 UnaryOp::Sinh => va.sinh(),
3321 UnaryOp::Cosh => va.cosh(),
3322 UnaryOp::Tanh => va.tanh(),
3323 UnaryOp::Asin => va.asin(),
3324 UnaryOp::Acosh => va.acosh(),
3325 UnaryOp::Asinh => va.asinh(),
3326 UnaryOp::Atanh => va.atanh(),
3327 UnaryOp::Erf => crate::nl_tape::erf(va),
3328 UnaryOp::XLogX => crate::nl_tape::xlogx(va),
3329 }
3330 }
3331 Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
3332 Expr::MinList(args) => args
3333 .iter()
3334 .map(|a| eval_expr(a, x))
3335 .fold(Number::INFINITY, Number::min),
3336 Expr::MaxList(args) => args
3337 .iter()
3338 .map(|a| eval_expr(a, x))
3339 .fold(Number::NEG_INFINITY, Number::max),
3340 Expr::Compare(op, a, b) => {
3341 let va = eval_expr(a, x);
3342 let vb = eval_expr(b, x);
3343 let truth = match op {
3344 CmpOp::Lt => va < vb,
3345 CmpOp::Le => va <= vb,
3346 CmpOp::Eq => va == vb,
3347 CmpOp::Ge => va >= vb,
3348 CmpOp::Gt => va > vb,
3349 CmpOp::Ne => va != vb,
3350 };
3351 if truth { 1.0 } else { 0.0 }
3352 }
3353 Expr::And(a, b) => {
3354 if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
3355 1.0
3356 } else {
3357 0.0
3358 }
3359 }
3360 Expr::Or(a, b) => {
3361 if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
3362 1.0
3363 } else {
3364 0.0
3365 }
3366 }
3367 Expr::Not(a) => {
3368 if eval_expr(a, x) == 0.0 {
3369 1.0
3370 } else {
3371 0.0
3372 }
3373 }
3374 Expr::Cond { cond, then_, else_ } => {
3375 if eval_expr(cond, x) != 0.0 {
3376 eval_expr(then_, x)
3377 } else {
3378 eval_expr(else_, x)
3379 }
3380 }
3381 Expr::Cse(body) => eval_expr(body, x),
3382 Expr::Funcall { .. } => panic!(
3383 "eval_expr: AMPL imported function called without an external resolver; \
3384 evaluate through the tape AD path (Tape::build_with_externals) instead"
3385 ),
3386 }
3387}
3388
3389/// Index of the active operand of an n-ary min (`want_min = true`) or
3390/// max (`want_min = false`) list at point `x`: the smallest / largest
3391/// value, with ties resolved to the first such operand (the
3392/// conventional subgradient choice). Returns `None` for an empty list.
3393fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
3394 let mut best: Option<(usize, Number)> = None;
3395 for (i, a) in args.iter().enumerate() {
3396 let v = eval_expr(a, x);
3397 match best {
3398 None => best = Some((i, v)),
3399 Some((_, bv)) => {
3400 // Strict comparison keeps the FIRST extremal operand on
3401 // ties, matching the subgradient convention used by Abs
3402 // and Select elsewhere in the tape.
3403 if (want_min && v < bv) || (!want_min && v > bv) {
3404 best = Some((i, v));
3405 }
3406 }
3407 }
3408 }
3409 best.map(|(i, _)| i)
3410}
3411
3412/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
3413pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
3414 match e {
3415 Expr::Const(_) => {}
3416 Expr::Var(i) => grad[*i] += seed,
3417 Expr::Binary(op, a, b) => {
3418 let va = eval_expr(a, x);
3419 let vb = eval_expr(b, x);
3420 match op {
3421 BinOp::Add => {
3422 grad_expr(a, x, seed, grad);
3423 grad_expr(b, x, seed, grad);
3424 }
3425 BinOp::Sub => {
3426 grad_expr(a, x, seed, grad);
3427 grad_expr(b, x, -seed, grad);
3428 }
3429 BinOp::Mul => {
3430 grad_expr(a, x, seed * vb, grad);
3431 grad_expr(b, x, seed * va, grad);
3432 }
3433 BinOp::Div => {
3434 grad_expr(a, x, seed / vb, grad);
3435 grad_expr(b, x, -seed * va / (vb * vb), grad);
3436 }
3437 BinOp::Pow => {
3438 // d/da: b * a^(b-1)
3439 let dpa = vb * va.powf(vb - 1.0);
3440 grad_expr(a, x, seed * dpa, grad);
3441 // d/db: a^b * ln(a) (only valid for a>0; simple branch)
3442 if va > 0.0 {
3443 let dpb = va.powf(vb) * va.ln();
3444 grad_expr(b, x, seed * dpb, grad);
3445 }
3446 }
3447 BinOp::Atan2 => {
3448 // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
3449 let d = va * va + vb * vb;
3450 grad_expr(a, x, seed * vb / d, grad);
3451 grad_expr(b, x, -seed * va / d, grad);
3452 }
3453 BinOp::CEntropy => {
3454 grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
3455 grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
3456 }
3457 }
3458 }
3459 Expr::Unary(op, a) => {
3460 let va = eval_expr(a, x);
3461 let d = match op {
3462 UnaryOp::Neg => -1.0,
3463 UnaryOp::Sqrt => 0.5 / va.sqrt(),
3464 UnaryOp::Log => 1.0 / va,
3465 UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
3466 UnaryOp::Exp => va.exp(),
3467 UnaryOp::Abs => {
3468 if va > 0.0 {
3469 1.0
3470 } else if va < 0.0 {
3471 -1.0
3472 } else {
3473 0.0
3474 }
3475 }
3476 UnaryOp::Sin => va.cos(),
3477 UnaryOp::Cos => -va.sin(),
3478 UnaryOp::Tan => {
3479 let t = va.tan();
3480 1.0 + t * t
3481 }
3482 UnaryOp::Atan => 1.0 / (1.0 + va * va),
3483 UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
3484 UnaryOp::Sinh => va.cosh(),
3485 UnaryOp::Cosh => va.sinh(),
3486 UnaryOp::Tanh => {
3487 let t = va.tanh();
3488 1.0 - t * t
3489 }
3490 UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
3491 UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
3492 UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
3493 UnaryOp::Atanh => 1.0 / (1.0 - va * va),
3494 UnaryOp::Erf => crate::nl_tape::erf_d1(va),
3495 UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
3496 };
3497 grad_expr(a, x, seed * d, grad);
3498 }
3499 Expr::Sum(args) => {
3500 for arg in args {
3501 grad_expr(arg, x, seed, grad);
3502 }
3503 }
3504 // min/max are piecewise linear: the seed flows only through the
3505 // currently-active (smallest / largest) operand — a subgradient.
3506 // Ties resolve to the first such operand. Empty list: no operand,
3507 // no derivative (matches the ±inf eval fold).
3508 Expr::MinList(args) => {
3509 if let Some(k) = argmin_argmax(args, x, true) {
3510 grad_expr(&args[k], x, seed, grad);
3511 }
3512 }
3513 Expr::MaxList(args) => {
3514 if let Some(k) = argmin_argmax(args, x, false) {
3515 grad_expr(&args[k], x, seed, grad);
3516 }
3517 }
3518 // Comparisons and logical connectives are piecewise constant:
3519 // zero derivative, so no seed propagates into their operands.
3520 Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3521 // if-then-else: differentiate only the active branch. The
3522 // branch-switch discontinuity contributes no derivative.
3523 Expr::Cond { cond, then_, else_ } => {
3524 if eval_expr(cond, x) != 0.0 {
3525 grad_expr(then_, x, seed, grad);
3526 } else {
3527 grad_expr(else_, x, seed, grad);
3528 }
3529 }
3530 Expr::Cse(body) => grad_expr(body, x, seed, grad),
3531 Expr::Funcall { .. } => {
3532 panic!("grad_expr: AMPL imported function called without an external resolver")
3533 }
3534 }
3535}
3536
3537/// Walk `e` and insert every `Var(i)` index into `out`.
3538///
3539/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3540/// identity. Without that this is Θ(2^depth) on a DAG that shares
3541/// subexpressions — each reference re-walks the whole body — and presolve
3542/// calls this on every solve (`get_variables_linearity`). Skipping a
3543/// repeat visit cannot change the answer: `out` is a set, and a second
3544/// walk of the same body inserts exactly the indices the first already did.
3545/// [`collect_vars`] over a `V` body whose earlier `Cse` references already
3546/// have their variable sets in `cse_vars` (indexed through `index`): those are
3547/// read, not re-walked (gh#986 review). The recursion is through `e`'s own
3548/// syntax only, which the parser has just built on this stack.
3549fn collect_vars_seeded(
3550 e: &Expr,
3551 out: &mut BTreeSet<usize>,
3552 index: &std::collections::HashMap<*const Expr, usize>,
3553 cse_vars: &[Vec<u32>],
3554) {
3555 let mut kids: Vec<&Expr> = vec![e];
3556 while let Some(e) = kids.pop() {
3557 match e {
3558 Expr::Const(_) => {}
3559 Expr::Var(i) => {
3560 out.insert(*i);
3561 }
3562 Expr::Cse(body) => match index.get(&Arc::as_ptr(body)) {
3563 Some(&k) => out.extend(cse_vars[k].iter().map(|&v| v as usize)),
3564 None => collect_vars(body, out),
3565 },
3566 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3567 kids.push(a);
3568 kids.push(b);
3569 }
3570 Expr::Unary(_, a) | Expr::Not(a) => kids.push(a),
3571 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => kids.extend(args.iter()),
3572 Expr::Cond { cond, then_, else_ } => {
3573 kids.push(cond);
3574 kids.push(then_);
3575 kids.push(else_);
3576 }
3577 Expr::Funcall { args, .. } => {
3578 for a in args {
3579 if let FuncallArg::Real(e) = a {
3580 kids.push(e);
3581 }
3582 }
3583 }
3584 }
3585 }
3586}
3587
3588pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3589 // `HashSet::new` does not allocate until the first insert, so an
3590 // expression with no CSEs pays nothing for the memo.
3591 let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3592 collect_vars_memo(e, out, &mut seen);
3593}
3594
3595fn collect_vars_memo(
3596 e: &Expr,
3597 out: &mut BTreeSet<usize>,
3598 seen: &mut std::collections::HashSet<*const Expr>,
3599) {
3600 match e {
3601 Expr::Const(_) => {}
3602 Expr::Var(i) => {
3603 out.insert(*i);
3604 }
3605 Expr::Binary(_, a, b) => {
3606 collect_vars_memo(a, out, seen);
3607 collect_vars_memo(b, out, seen);
3608 }
3609 Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3610 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3611 for a in args {
3612 collect_vars_memo(a, out, seen);
3613 }
3614 }
3615 // Collect from every child, including the condition: even
3616 // though the comparison/branch-test contributes no derivative,
3617 // the variables it reads are genuinely "used" by the problem,
3618 // and being conservative here only ever adds structural zeros
3619 // to the Jacobian/Hessian (never drops a real nonzero).
3620 Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3621 collect_vars_memo(a, out, seen);
3622 collect_vars_memo(b, out, seen);
3623 }
3624 Expr::Not(a) => collect_vars_memo(a, out, seen),
3625 Expr::Cond { cond, then_, else_ } => {
3626 collect_vars_memo(cond, out, seen);
3627 collect_vars_memo(then_, out, seen);
3628 collect_vars_memo(else_, out, seen);
3629 }
3630 Expr::Cse(body) => {
3631 if seen.insert(Arc::as_ptr(body)) {
3632 collect_vars_memo(body, out, seen);
3633 }
3634 }
3635 Expr::Funcall { args, .. } => {
3636 for a in args {
3637 if let FuncallArg::Real(e) = a {
3638 collect_vars_memo(e, out, seen);
3639 }
3640 }
3641 }
3642 }
3643}
3644
3645// --------------------------------------------------------------------
3646// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3647// Jacobian, and Hessian. Built once at construction; every solve-time
3648// callback is a tape sweep, no expression-tree recursion.
3649// --------------------------------------------------------------------
3650
3651/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3652/// After a directional Hessian-vector product `compressed = H · s_c`,
3653/// the entry at row `row` came uniquely from column `col` (because
3654/// no two columns of color `c` share any nonzero row), so we
3655/// scatter `compressed[row]` into `values[hess_idx]`.
3656#[derive(Debug, Clone)]
3657struct ColorWrite {
3658 row: u32,
3659 hess_idx: u32,
3660}
3661
3662/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3663/// **shared prelude** holding every CSE body referenced by two or more
3664/// summands, evaluated once per sweep.
3665///
3666/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3667/// defined variable (`V` segment) referenced from many rows is re-emitted —
3668/// and re-evaluated — once per reference. That is invisible on most models
3669/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3670/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3671/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3672/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3673/// search. See pounce#476.
3674///
3675/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3676/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3677/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3678/// overhead the flat tapes do not, so each derivative order has to earn
3679/// its switch.
3680#[derive(Debug, Clone)]
3681struct ConHybrid {
3682 tape: HybridTape,
3683 /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3684 /// Length `m + 1`.
3685 row_start: Vec<usize>,
3686 /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3687 prelude_vals: Vec<f64>,
3688 /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3689 local_vals: Vec<f64>,
3690 /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3691 /// value arenas above. `gradient_summand` zeroes only the slots a
3692 /// summand actually reaches, so these are allocated once and reused.
3693 local_adj: Vec<f64>,
3694 prelude_adj: Vec<f64>,
3695 /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3696 /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3697 /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3698 use_for_jac: bool,
3699 /// Whether `eval_h` routes the constraint block through the shared
3700 /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3701 use_for_hess: bool,
3702 // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3703 // `try_new` after the Hessian coloring exists; cheap enough (a few
3704 // index tables plus one f64 per local op) to build whenever the
3705 // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3706 /// Forward values of every summand, packed at
3707 /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3708 /// fills it; every color then reuses the values, mirroring the flat
3709 /// path's forward-once-per-tape structure.
3710 local_vals_all: Vec<f64>,
3711 /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3712 local_off: Vec<usize>,
3713 /// Constraint row of each summand (the inverse of `row_start`), for
3714 /// the `λ[row]` weight lookup.
3715 summand_row: Vec<u32>,
3716 /// Per color: the summands whose variables fall in that color — the
3717 /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3718 /// exactly the live (color, summand) pairs.
3719 hess_color_summands: Vec<Vec<u32>>,
3720 /// Per color: the prelude slots that color's summands actually reach,
3721 /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3722 /// Both prelude sweeps run once per color, so walking the whole
3723 /// prelude each time would cost `n_colors × |prelude|` where the
3724 /// op-ratio gate assumes `|prelude|`; iterating the union of the
3725 /// color's `prelude_reach` sets makes the cost proportional to what
3726 /// is used, so the gate does not need an `n_colors` term. Stored as
3727 /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3728 /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3729 /// this struct is the one #552 made O(n²) by holding per-color dense
3730 /// arrays.
3731 hess_color_reach: Vec<u32>,
3732 hess_color_reach_off: Vec<usize>,
3733 /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3734 prelude_dot: Vec<f64>,
3735 /// First-/second-order prelude adjoint accumulators. NOT shared
3736 /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3737 /// between-colors invariant (`prelude_reverse_directional`'s
3738 /// consume-and-zero contract), while `gradient_summand` zeroes only
3739 /// the slots it is about to use and leaves them dirty afterwards —
3740 /// sharing the buffer would seed a later `eval_h` with a stale
3741 /// Jacobian adjoint.
3742 hess_prelude_adj: Vec<f64>,
3743 prelude_adj_dot: Vec<f64>,
3744 /// Local tangent / second-order adjoint arenas, sized to
3745 /// `tape.max_summand_ops()`.
3746 local_dot: Vec<f64>,
3747 local_adj_dot: Vec<f64>,
3748}
3749
3750/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3751/// shared-CSE prelude.
3752///
3753/// `eval_g` takes the hybrid path unconditionally because it only needs
3754/// *values*: the prelude is swept once for the whole constraint block and
3755/// the saving is the full op-count ratio. The Jacobian is different. Each
3756/// row needs its own gradient, so only the forward sweep can be shared —
3757/// the reverse sweep still walks each summand's `prelude_reach`
3758/// separately, and it pays a per-op cost the flat tape does not: a nested
3759/// `SummandOp` dispatch and an indirected walk over a reach list instead
3760/// of a straight loop over a contiguous `Vec<TapeOp>`.
3761///
3762/// So the hybrid Jacobian wins only when the shared bodies are large
3763/// enough for the halved forward sweep to outweigh that overhead.
3764/// Measured on chain models at CSE redundancy 40, varying the body size
3765/// (`eval_jac_g`, flat → hybrid):
3766///
3767/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3768/// |---|---|---|---|---|---|---|---|---|
3769/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3770///
3771/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3772/// a model that does not clearly benefit stays on the flat path. For
3773/// reference `robot_a` (#476) measures 4.03×.
3774const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3775
3776/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3777/// block through the shared-CSE prelude (issue #557).
3778///
3779/// The Hessian shares **both** second-order sweeps of the prelude, not just
3780/// the forward one: the coloring hands every summand of a color the same
3781/// seed vector, so the prelude forward tangent runs once per color, and —
3782/// because reverse-over-tangent is linear in its adjoint seeds — the
3783/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3784/// unit-weight prelude reverse sweep per color. That is why its crossover
3785/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3786/// Jacobian can only share the forward half, and per-row gradients forbid
3787/// batching its reverse sweeps at all.
3788///
3789/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3790/// bodies), varying the body size — the same protocol as the Jacobian
3791/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3792/// flat/hybrid pairs per point**, with the observed range, because
3793/// single runs on a shared machine are not reproducible to the precision
3794/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3795/// single ratio:
3796///
3797/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3798/// |---|---|---|---|---|---|---|---|---|
3799/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3800/// | 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 |
3801///
3802/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3803/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3804/// break-even and individual runs lose. Setting it there costs only a
3805/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3806/// bit-identically, so a gate placed too high is merely conservative while
3807/// one placed too low risks a real regression. For reference `robot_a`
3808/// (#476) measures 4.03×.
3809const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3810
3811// `Clone` supports the batched-solve path (pounce#126): one parsed
3812// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3813// the clone is cheap relative to a solve) and each clone gets its own
3814// bound / starting-point overrides via [`NlTnlp::variant`].
3815#[derive(Debug, Clone)]
3816pub struct NlTnlp {
3817 prob: NlProblem,
3818 /// Per-summand objective tapes (one `Tape` per top-level
3819 /// summand after `split_top_sums`).
3820 obj_tapes: Vec<Tape>,
3821 /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3822 /// one `Tape` per summand of constraint `i`.
3823 con_tapes: Vec<Vec<Tape>>,
3824 /// Constraint-block tape with a **shared** CSE prelude, used by
3825 /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3826 /// `eval_g` on the per-summand `con_tapes` above.
3827 con_hybrid: Option<ConHybrid>,
3828 /// The degree-≤2 objective and rows, evaluated from their constant
3829 /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3830 /// has an **empty** `con_tapes` entry, so every loop over the tapes
3831 /// naturally contributes nothing for it and only the sites that consult
3832 /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3833 /// `eval_h`, and `hessian_vector_products` — the last of which the design
3834 /// note's list of five omitted, and which is the one where an omission
3835 /// would be silent. Empty for a model with nothing recognized, in which
3836 /// case everything below behaves bit for bit as it did before this
3837 /// existed.
3838 quad: QuadraticStructure,
3839 /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3840 /// Empty when `quad` is — the pattern is then all tape. The coloring is
3841 /// built over this subset alone, which is what stops one dense quadratic
3842 /// block from forcing the color count to `n` for the benefit of tapes
3843 /// that no longer exist.
3844 h_tape_mask: Vec<bool>,
3845 /// Lower-triangle Hessian sparsity (row >= col), one entry per
3846 /// structurally nonzero second derivative in the Lagrangian.
3847 h_irow: Vec<i32>,
3848 h_jcol: Vec<i32>,
3849 /// Per-row sorted variable indices for the constraint Jacobian.
3850 jac_cols: Vec<Vec<usize>>,
3851 jac_nnz: usize,
3852 /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3853 /// is in color `c`, else `0.0`. Each color is a set of
3854 /// variables whose Hessian columns have pairwise-disjoint
3855 /// nonzero rows; one directional H·s product per color
3856 /// recovers all those columns simultaneously. Dense for
3857 /// O(1) lookup in the per-op forward tangent.
3858 seeds: Vec<Vec<f64>>,
3859 /// Per-color decoding table: for each `(row, hess_idx)` entry,
3860 /// scatter `compressed_c[row] -> values[hess_idx]` after the
3861 /// per-color directional product.
3862 decoding: Vec<Vec<ColorWrite>>,
3863 /// For each objective tape: the distinct colors of vars it
3864 /// references. Lets us skip tape × color pairs where the tape
3865 /// has zero overlap with the color's seed.
3866 obj_tape_colors: Vec<Vec<u32>>,
3867 /// Same as `obj_tape_colors` but per constraint × summand.
3868 con_tape_colors: Vec<Vec<Vec<u32>>>,
3869 /// The same work, color-major, for the parallel flat Hessian path.
3870 hess_color_index: HessColorIndex,
3871 /// Offset of each row's run in the Jacobian `values` array, length
3872 /// `m + 1`. Lets a parallel row group take one contiguous slice.
3873 jac_row_off: Vec<u32>,
3874 /// Per-worker scratch for the parallel Jacobian, kept across evaluations:
3875 /// each holds a length-`n` gradient row, too big to allocate per call.
3876 #[cfg(not(target_arch = "wasm32"))]
3877 jac_scratch_pool: Vec<JacScratch>,
3878 /// Color of each variable's Hessian column, `u32::MAX` for a column
3879 /// that needs no pass of its own. Kept so
3880 /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3881 /// pass in `compressed`.
3882 var_color: Vec<u32>,
3883 /// Columns peeled out of the conflict structure and given singleton
3884 /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3885 peeled_cols: Vec<u32>,
3886 final_x: Option<Vec<Number>>,
3887 final_obj: Number,
3888 /// Converged constraint multipliers (length `m`, original `.nl` row
3889 /// order, user convention), captured from the same `finalize_solution`
3890 /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3891 /// block without re-deriving it from the algorithm's internal `y_c` /
3892 /// `y_d` split and scaling.
3893 final_lambda: Option<Vec<Number>>,
3894 /// Converged bound multipliers (length `n` each, Ipopt's internal
3895 /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3896 /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3897 /// reads for reduced costs.
3898 final_z_l: Option<Vec<Number>>,
3899 final_z_u: Option<Vec<Number>>,
3900 /// Per-row Jacobian accumulator (length n).
3901 scratch_row_grad: Vec<f64>,
3902 /// Scratch buffers for `Tape::hessian_directional` (each sized
3903 /// to `max_tape_n`).
3904 vals_scratch: Vec<f64>,
3905 dot_scratch: Vec<f64>,
3906 adj_scratch: Vec<f64>,
3907 adj_dot_scratch: Vec<f64>,
3908 /// Per-color compressed Hessian-vector results, sized to
3909 /// `prob.n`. Reused across `eval_h` calls but allocated once.
3910 compressed: Vec<Vec<f64>>,
3911 /// Per-direction "carries signal" mask for `hessian_vector_products`,
3912 /// kept here rather than allocated per call. This crate holds an
3913 /// explicit no-per-call-allocation line on the tape sweeps (see
3914 /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3915 /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3916 /// Krylov iteration.
3917 hvp_live: Vec<bool>,
3918 /// Model-derived scaling factors (gh #703), computed on demand by
3919 /// [`NlTnlp::enable_curvature_scaling`] and served through
3920 /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3921 /// the state every solve that does not select `curvature-based` stays
3922 /// in — computing them costs a pass over every stored Hessian entry
3923 /// and nothing else reads them.
3924 curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3925}
3926
3927// ---------------------------------------------------------------------
3928// Human-readable equation rendering (`print equation` in the debugger).
3929//
3930// Turns a parsed constraint back into infix text using the model's
3931// variable / constraint names, so the debugger can show the actual
3932// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3933// bare row index. This is the "print the specific equation, with
3934// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3935// argue makes equation-oriented model diagnostics actionable.
3936//
3937// The renderer is intentionally separate from the evaluation `Tape`:
3938// tapes are lossy for display (CSEs flattened, externals opaque),
3939// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3940// ---------------------------------------------------------------------
3941
3942/// Binding strength for parenthesization. Higher binds tighter.
3943const P_ADD: u8 = 10;
3944const P_MUL: u8 = 20;
3945const P_NEG: u8 = 30;
3946const P_POW: u8 = 40;
3947const P_ATOM: u8 = 100;
3948
3949/// Format a numeric literal compactly: integers without a trailing `.0`,
3950/// everything else via the shortest round-tripping `f64` form.
3951fn fmt_num(x: Number) -> String {
3952 if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3953 format!("{}", x as i64)
3954 } else {
3955 format!("{x}")
3956 }
3957}
3958
3959/// Display label for variable `i`: its `.col` name when present, else
3960/// `x[i]`.
3961fn var_label(i: usize, var_names: &[String]) -> String {
3962 match var_names.get(i) {
3963 Some(s) if !s.is_empty() => s.clone(),
3964 _ => format!("x[{i}]"),
3965 }
3966}
3967
3968/// Precedence of an expression's top operator (for child wrapping).
3969fn expr_prec(e: &Expr) -> u8 {
3970 match e {
3971 Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3972 Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3973 Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3974 Expr::Binary(BinOp::Pow, ..) => P_POW,
3975 Expr::Cse(inner) => expr_prec(inner),
3976 // Everything else renders as an atom / `f(...)` form.
3977 _ => P_ATOM,
3978 }
3979}
3980
3981/// Render an expression as infix text, using `var_names` for variable
3982/// labels where available (`x[i]` otherwise).
3983///
3984/// The debugger reaches the renderer through the constraint/objective
3985/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3986/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3987/// able to *see* the expression you just built is most of the debugging
3988/// story.
3989///
3990/// `Cse` bodies are inlined at every occurrence, so the output of a
3991/// heavily-shared DAG can be far larger than the DAG itself. Callers
3992/// rendering user-built expressions should bound the input first.
3993pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3994 render_expr(e, var_names, &[])
3995}
3996
3997/// Render `e`, wrapping in parentheses iff its precedence is looser than
3998/// `min_prec`.
3999fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
4000 let s = render_expr(e, vn, funcs);
4001 if expr_prec(e) < min_prec {
4002 format!("({s})")
4003 } else {
4004 s
4005 }
4006}
4007
4008fn unary_name(op: UnaryOp) -> &'static str {
4009 match op {
4010 UnaryOp::Neg => "-",
4011 UnaryOp::Sqrt => "sqrt",
4012 UnaryOp::Log => "log",
4013 UnaryOp::Exp => "exp",
4014 UnaryOp::Abs => "abs",
4015 UnaryOp::Sin => "sin",
4016 UnaryOp::Cos => "cos",
4017 UnaryOp::Log10 => "log10",
4018 UnaryOp::Tan => "tan",
4019 UnaryOp::Atan => "atan",
4020 UnaryOp::Acos => "acos",
4021 UnaryOp::Sinh => "sinh",
4022 UnaryOp::Cosh => "cosh",
4023 UnaryOp::Tanh => "tanh",
4024 UnaryOp::Asin => "asin",
4025 UnaryOp::Acosh => "acosh",
4026 UnaryOp::Asinh => "asinh",
4027 UnaryOp::Atanh => "atanh",
4028 UnaryOp::Erf => "erf",
4029 // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
4030 // the rendered text is read by humans debugging a model and must not
4031 // imply a sign the op does not have.
4032 UnaryOp::XLogX => "xlogx",
4033 }
4034}
4035
4036fn cmp_sym(op: CmpOp) -> &'static str {
4037 match op {
4038 CmpOp::Lt => "<",
4039 CmpOp::Le => "<=",
4040 CmpOp::Eq => "==",
4041 CmpOp::Ge => ">=",
4042 CmpOp::Gt => ">",
4043 CmpOp::Ne => "!=",
4044 }
4045}
4046
4047/// Append an additive sub-term with a tidy sign: a rendered term that
4048/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
4049/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
4050/// the term is itself a sum. The first term is emitted verbatim.
4051fn push_additive(out: &mut String, rendered: &str, first: bool) {
4052 if first {
4053 out.push_str(rendered);
4054 } else if let Some(rest) = rendered.strip_prefix('-') {
4055 out.push_str(" - ");
4056 out.push_str(rest);
4057 } else {
4058 out.push_str(" + ");
4059 out.push_str(rendered);
4060 }
4061}
4062
4063/// Render an [`Expr`] DAG to infix text using model names.
4064fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
4065 match e {
4066 Expr::Const(c) => fmt_num(*c),
4067 Expr::Var(i) => var_label(*i, vn),
4068 Expr::Binary(op, l, r) => match op {
4069 BinOp::Add => {
4070 let mut s = render_prec(l, P_ADD, vn, funcs);
4071 push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
4072 s
4073 }
4074 // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
4075 BinOp::Sub => format!(
4076 "{} - {}",
4077 render_prec(l, P_ADD, vn, funcs),
4078 render_prec(r, P_ADD + 1, vn, funcs)
4079 ),
4080 BinOp::Mul => format!(
4081 "{}*{}",
4082 render_prec(l, P_MUL, vn, funcs),
4083 render_prec(r, P_MUL, vn, funcs)
4084 ),
4085 BinOp::Div => format!(
4086 "{}/{}",
4087 render_prec(l, P_MUL, vn, funcs),
4088 render_prec(r, P_MUL + 1, vn, funcs)
4089 ),
4090 // Pow is right-associative: tighten the left operand instead.
4091 BinOp::Pow => format!(
4092 "{}^{}",
4093 render_prec(l, P_POW + 1, vn, funcs),
4094 render_prec(r, P_POW, vn, funcs)
4095 ),
4096 BinOp::Atan2 => format!(
4097 "atan2({}, {})",
4098 render_expr(l, vn, funcs),
4099 render_expr(r, vn, funcs)
4100 ),
4101 BinOp::CEntropy => format!(
4102 "centropy({}, {})",
4103 render_expr(l, vn, funcs),
4104 render_expr(r, vn, funcs)
4105 ),
4106 },
4107 Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
4108 Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
4109 Expr::Sum(xs) => {
4110 if xs.is_empty() {
4111 "0".to_string()
4112 } else {
4113 let mut s = String::new();
4114 for (k, x) in xs.iter().enumerate() {
4115 push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
4116 }
4117 s
4118 }
4119 }
4120 Expr::Cse(inner) => render_expr(inner, vn, funcs),
4121 Expr::Funcall { id, args } => {
4122 let name = funcs
4123 .iter()
4124 .find(|f| f.id == *id)
4125 .map(|f| f.name.clone())
4126 .unwrap_or_else(|| format!("extern#{id}"));
4127 let parts: Vec<String> = args
4128 .iter()
4129 .map(|a| match a {
4130 FuncallArg::Real(x) => render_expr(x, vn, funcs),
4131 FuncallArg::Str(s) => format!("{s:?}"),
4132 })
4133 .collect();
4134 format!("{name}({})", parts.join(", "))
4135 }
4136 Expr::Compare(op, a, b) => format!(
4137 "({} {} {})",
4138 render_expr(a, vn, funcs),
4139 cmp_sym(*op),
4140 render_expr(b, vn, funcs)
4141 ),
4142 Expr::And(a, b) => format!(
4143 "({} && {})",
4144 render_expr(a, vn, funcs),
4145 render_expr(b, vn, funcs)
4146 ),
4147 Expr::Or(a, b) => format!(
4148 "({} || {})",
4149 render_expr(a, vn, funcs),
4150 render_expr(b, vn, funcs)
4151 ),
4152 Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
4153 Expr::Cond { cond, then_, else_ } => format!(
4154 "if({}, {}, {})",
4155 render_expr(cond, vn, funcs),
4156 render_expr(then_, vn, funcs),
4157 render_expr(else_, vn, funcs)
4158 ),
4159 Expr::MinList(xs) => format!(
4160 "min({})",
4161 xs.iter()
4162 .map(|x| render_expr(x, vn, funcs))
4163 .collect::<Vec<_>>()
4164 .join(", ")
4165 ),
4166 Expr::MaxList(xs) => format!(
4167 "max({})",
4168 xs.iter()
4169 .map(|x| render_expr(x, vn, funcs))
4170 .collect::<Vec<_>>()
4171 .join(", ")
4172 ),
4173 }
4174}
4175
4176/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
4177/// `a + -2*b`). Returns `""` when there are no linear terms.
4178fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
4179 let mut out = String::new();
4180 // The `.nl` linear part carries an entry for every variable in the
4181 // row's Jacobian, including a 0 coefficient for variables that appear
4182 // only *nonlinearly* (they're rendered in the nonlinear part). Skip
4183 // those zeros so the equation reads as written, not as a sparsity map.
4184 let mut first = true;
4185 for (var, coef) in linear {
4186 if *coef == 0.0 {
4187 continue;
4188 }
4189 let neg = *coef < 0.0;
4190 let mag = coef.abs();
4191 let term = if mag == 1.0 {
4192 var_label(*var, vn)
4193 } else {
4194 format!("{}*{}", fmt_num(mag), var_label(*var, vn))
4195 };
4196 if first {
4197 if neg {
4198 out.push('-');
4199 }
4200 out.push_str(&term);
4201 first = false;
4202 } else {
4203 out.push_str(if neg { " - " } else { " + " });
4204 out.push_str(&term);
4205 }
4206 }
4207 out
4208}
4209
4210/// Render the constraint body (linear + nonlinear parts combined).
4211fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
4212 let mut s = render_linear(linear, &prob.var_names);
4213 let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
4214 if !nl_is_zero {
4215 let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
4216 if s.is_empty() {
4217 s = nl;
4218 } else {
4219 push_additive(&mut s, &nl, false);
4220 }
4221 }
4222 if s.is_empty() {
4223 s = "0".to_string();
4224 }
4225 s
4226}
4227
4228/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
4229/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
4230/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
4231pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
4232 // Diagnostic path: a recognized body has no tree, so this is one of
4233 // the places that pays to rebuild one. It runs once per rendered row.
4234 let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
4235 let lo = prob.g_l[k];
4236 let hi = prob.g_u[k];
4237 const INF: Number = 1.0e19;
4238 let has_lo = lo > -INF;
4239 let has_hi = hi < INF;
4240 match (has_lo, has_hi) {
4241 (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
4242 (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
4243 (true, false) => format!("{body} >= {}", fmt_num(lo)),
4244 (false, true) => format!("{body} <= {}", fmt_num(hi)),
4245 (false, false) => format!("{body} (free)"),
4246 }
4247}
4248
4249/// Render every constraint to text, index-aligned to `g` (original `.nl`
4250/// row order). Used to build the debugger's static equation book.
4251pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
4252 (0..prob.m)
4253 .map(|k| render_constraint_equation(prob, k))
4254 .collect()
4255}
4256
4257/// Structural sparsity of the constraint Jacobian as flat 0-based
4258/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
4259/// structurally depends on — the union of its linear support and the
4260/// `Var(i)` indices appearing anywhere in its nonlinear tree
4261/// ([`collect_vars`]). Sorted and deduplicated within each row.
4262///
4263/// This is the input to the debugger's Dulmage–Mendelsohn
4264/// structural-rank check (`diagnose`), which names the over-determined
4265/// (candidate redundant / inconsistent) equations and under-determined
4266/// variables. Naming the dependent rows — rather than reporting
4267/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
4268/// equation-oriented model debugging. See
4269/// <https://doi.org/10.69997/sct.147875>.
4270pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
4271 let mut irow: Vec<Index> = Vec::new();
4272 let mut jcol: Vec<Index> = Vec::new();
4273 let mut support: BTreeSet<usize> = BTreeSet::new();
4274 for k in 0..prob.m {
4275 support.clear();
4276 for &(j, _coef) in &prob.con_linear[k] {
4277 support.insert(j);
4278 }
4279 prob.con_nonlinear[k].collect_vars(&mut support);
4280 for &j in &support {
4281 irow.push(k as Index);
4282 jcol.push(j as Index);
4283 }
4284 }
4285 (irow, jcol)
4286}
4287
4288/// Flatten an additive expression tree into independent summand
4289/// expressions, each of which becomes its own Hessian tape.
4290///
4291/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
4292/// each summand tape once *per color the summand touches*, so the
4293/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
4294/// small (few variables → few colors) is what makes a sparse Hessian
4295/// cheap. A single fused tape spanning all `n` variables, by
4296/// contrast, is walked once per color → `O(n · tape_len)`, which on a
4297/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
4298/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
4299///
4300/// We therefore descend through the *affine* envelope of the sum, not
4301/// just `+`/`Sum`:
4302///
4303/// * `Neg(x)` → split `x`, negate each summand
4304/// * `Sub(l, r)` → split `l`; split `r`, negate each summand
4305/// * `c * x` / `x * c` → split `x`, scale each summand by `c`
4306/// * `x / c` → split `x`, scale each summand by `1/c`
4307///
4308/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
4309/// least-squares / max-entropy shapes) still decomposes to its leaf
4310/// terms instead of collapsing into one giant tape. The carried
4311/// `factor` is materialised onto each leaf only when it differs from
4312/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
4313/// is unchanged and the per-summand op count grows by at most one.
4314fn split_top_sums(expr: &Expr) -> Vec<Expr> {
4315 let mut out = Vec::new();
4316 fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4317 if factor == 1.0 {
4318 out.push(e.clone());
4319 } else if factor == -1.0 {
4320 out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
4321 } else {
4322 out.push(Expr::Binary(
4323 BinOp::Mul,
4324 Box::new(Expr::Const(factor)),
4325 Box::new(e.clone()),
4326 ));
4327 }
4328 }
4329 fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4330 match e {
4331 Expr::Sum(terms) => {
4332 for t in terms {
4333 go(t, factor, out);
4334 }
4335 }
4336 Expr::Binary(BinOp::Add, l, r) => {
4337 go(l, factor, out);
4338 go(r, factor, out);
4339 }
4340 Expr::Binary(BinOp::Sub, l, r) => {
4341 go(l, factor, out);
4342 go(r, -factor, out);
4343 }
4344 Expr::Unary(UnaryOp::Neg, x) => {
4345 go(x, -factor, out);
4346 }
4347 // Affine scaling: distribute a constant coefficient into
4348 // the summands so a leading `c·(Σ …)` still splits.
4349 Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
4350 (Expr::Const(c), _) => go(r, factor * c, out),
4351 (_, Expr::Const(c)) => go(l, factor * c, out),
4352 _ => push_leaf(e, factor, out),
4353 },
4354 Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
4355 Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
4356 _ => push_leaf(e, factor, out),
4357 },
4358 _ => push_leaf(e, factor, out),
4359 }
4360 }
4361 go(expr, 1.0, &mut out);
4362 if out.is_empty() {
4363 out.push(Expr::Const(0.0));
4364 }
4365 out
4366}
4367
4368/// Greedy column coloring of a symmetric sparsity pattern stored
4369/// as lower-triangle pairs.
4370///
4371/// Builds the column-intersection graph: columns `c1` and `c2` are
4372/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
4373/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
4374/// satisfies the direct-recovery condition for symmetric Hessians
4375/// (Coleman-Moré): for any color, the columns it contains have
4376/// pairwise disjoint row supports, so a single H·s product
4377/// recovers them all unambiguously.
4378///
4379/// Returns `(var_color, n_colors)` where `var_color[k]` is the
4380/// color assigned to variable `k`, or `u32::MAX` for variables
4381/// not in any Hessian pair (they contribute nothing and don't
4382/// need a color).
4383/// A column is treated as **dense** — and peeled out of the coloring —
4384/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
4385/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
4386/// factor keeps uniformly-dense Hessians (where every column looks like
4387/// every other) on the plain coloring path, and the absolute floor stops
4388/// a very sparse average from declaring a 10-entry column "dense".
4389const DENSE_COL_FACTOR: usize = 16;
4390const DENSE_COL_MIN: usize = 32;
4391/// Largest relative error a peeled column may inflict on the smallest
4392/// entry recovered from its pass before
4393/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
4394///
4395/// The ratio is a worst-case bound — the pass's roundoff floor over the
4396/// smallest entry read out of it — and it is a *loose* one, by an amount
4397/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
4398/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
4399/// measures 4e-16. The cut is therefore calibrated on the corpus, not
4400/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
4401/// models that peel anything, the highest bound on a column that recovers
4402/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
4403/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
4404/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
4405///
4406/// 1e-8 sits below both, which deliberately buys correctness with speed:
4407/// the errors are asymmetric, since a false veto costs one model a coloring
4408/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
4409/// while a missed veto costs a solve its certificate. Five of the 56 take a
4410/// veto they do not need; none takes a wrong answer. Raising this constant
4411/// to buy those five back would put the cut inside a 2.8x window measured
4412/// on two model families, which is not a margin worth trading a certificate
4413/// for.
4414const PEEL_MAX_REL_ERR: f64 = 1e-8;
4415/// Hard cap on how many columns get peeled, applied on top of the
4416/// pay-for-itself rule in [`select_peeled_cols`].
4417const MAX_PEELED_COLS: usize = 256;
4418
4419/// Lower bound on the color count that results from peeling `peeled`.
4420///
4421/// Peeling costs one color per peeled column. On what remains, any row
4422/// with `d` surviving entries makes those `d` columns pairwise
4423/// conflicting, so the greedy walk needs at least `d` colors. Hence
4424/// `|peeled| + max surviving row degree` is a lower bound on the total,
4425/// computable in one O(nnz) pass — no coloring required.
4426///
4427/// The bound is what makes the choice decidable at all: the plain
4428/// coloring cannot be run as a comparison baseline, because on the
4429/// one-dense-row case the plain walk is itself O(n^2) — precisely the
4430/// blowup peeling exists to avoid.
4431fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
4432 let mut deg = vec![0usize; n];
4433 for &(i, j) in lower_pairs {
4434 if peeled[i] || peeled[j] {
4435 continue;
4436 }
4437 deg[j] += 1;
4438 if i != j {
4439 deg[i] += 1;
4440 }
4441 }
4442 let n_peeled = peeled.iter().filter(|&&p| p).count();
4443 n_peeled + deg.iter().copied().max().unwrap_or(0)
4444}
4445
4446/// Choose which of the candidate dense columns to actually peel.
4447///
4448/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
4449/// top `k` candidates by degree, over a doubling ladder of `k` up to
4450/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
4451/// so peeling has to earn its colors.
4452///
4453/// **Why not just truncate an over-long candidate list.** Cutting the
4454/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
4455/// columns that miss the cut stay in the conflict structure, so the base
4456/// color count is untouched and the singleton colors are pure addition.
4457/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
4458/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
4459/// regression in exactly the quantity peeling exists to reduce. The
4460/// bound above sees it: peeling `k` of several thousand equal-degree
4461/// columns leaves the surviving max degree unchanged, so every `k > 0`
4462/// scores strictly worse than peeling nothing.
4463///
4464/// **Why not a simple degree rule.** "Peel only columns denser than some
4465/// fraction of `n`" would refuse three rows of degree 10,000 in a
4466/// 200,000-variable model, where peeling three columns takes the
4467/// coloring from >= 10,000 down to a handful. The win depends on what
4468/// peeling leaves behind, not on the peeled column's degree alone.
4469fn select_peeled_cols(
4470 n: usize,
4471 lower_pairs: &[(usize, usize)],
4472 deg: &[usize],
4473 mut candidates: Vec<usize>,
4474) -> Vec<usize> {
4475 if candidates.is_empty() {
4476 return candidates;
4477 }
4478 // Worst offenders first; they remove the most conflict per color spent.
4479 candidates.sort_unstable_by(|&a, &b| deg[b].cmp(°[a]).then(a.cmp(&b)));
4480 candidates.truncate(MAX_PEELED_COLS);
4481
4482 let mut mask = vec![false; n];
4483 let mut best_k = 0usize;
4484 // Peeling nothing: the bound is just the largest row degree.
4485 let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
4486
4487 // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
4488 // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
4489 // mask only ever gains entries, so each step just marks the new slice.
4490 let mut marked = 0usize;
4491 let mut k = 1usize;
4492 loop {
4493 let k_now = k.min(candidates.len());
4494 for &j in &candidates[marked..k_now] {
4495 mask[j] = true;
4496 }
4497 marked = k_now;
4498 let bound = peel_color_bound(n, lower_pairs, &mask);
4499 if bound < best_bound {
4500 best_bound = bound;
4501 best_k = k_now;
4502 }
4503 if k_now == candidates.len() {
4504 break;
4505 }
4506 k *= 2;
4507 }
4508
4509 candidates.truncate(best_k);
4510 candidates
4511}
4512
4513/// Greedy distance-1 coloring of the Hessian's column-intersection
4514/// graph, with **dense columns peeled out**.
4515///
4516/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
4517/// marks a column that needs no directional product of its own: either
4518/// it has no Hessian entries at all, or every entry it has is recovered
4519/// from a peeled column's pass (see below).
4520///
4521/// # Why peeling
4522///
4523/// The plain coloring rule is "two columns may share a color when they
4524/// have no common nonzero row". A single **dense row** — one variable
4525/// multiplying a sum over all the others, a total-cost variable, a
4526/// shared design parameter — puts a nonzero in *every* column at that
4527/// row, so every pair of columns conflicts and the greedy walk hands out
4528/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
4529/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
4530/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
4531/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
4532///
4533/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
4534/// own singleton color: one directional product with seed `e_d` recovers
4535/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
4536/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
4537/// at row `j` of that same pass. So row `d` no longer constrains any
4538/// other column's color and is dropped from the conflict structure, and
4539/// the remaining columns colour on their genuine sparsity. On the
4540/// one-dense-row case above this takes `n_colors` from `n` to a handful.
4541///
4542/// # What peeling costs, and `peel_veto`
4543///
4544/// Recovering `H[d, j]` from column `d`'s pass is exact in real
4545/// arithmetic but not in floating point: the pass is accumulated at the
4546/// scale of the whole dense column, so every entry read out of it carries
4547/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
4548/// ordinary path — column `j`'s own pass — would have left a floor of
4549/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
4550/// dense column is well scaled, and they do on every peel-firing model in
4551/// the benchmark corpus but one. Where a peeled column spans a wide
4552/// dynamic range, though, that floor swamps its small entries: a column
4553/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4554///
4555/// Structure cannot see this — it is a property of the values — so
4556/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4557/// and passes the offenders back here in `peel_veto`, which bars them
4558/// from being peeled again. A vetoed column is colored normally, its row
4559/// returns to the conflict structure, and its entries go back to the
4560/// accurate path.
4561fn greedy_hessian_coloring(
4562 n: usize,
4563 lower_pairs: &[(usize, usize)],
4564 peel_veto: &[bool],
4565) -> (Vec<u32>, usize, Vec<bool>) {
4566 if n == 0 {
4567 return (Vec::new(), 0, Vec::new());
4568 }
4569
4570 // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4571 // i >= j contributes row i to column j and row j to column i; a
4572 // diagonal contributes once.
4573 let mut deg = vec![0usize; n];
4574 for &(i, j) in lower_pairs {
4575 deg[j] += 1;
4576 if i != j {
4577 deg[i] += 1;
4578 }
4579 }
4580
4581 // Pick the dense columns to peel.
4582 let total: usize = deg.iter().sum();
4583 let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4584 let mut peeled = vec![false; n];
4585 let candidates: Vec<usize> = (0..n)
4586 .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4587 .collect();
4588 let dense = select_peeled_cols(n, lower_pairs, °, candidates);
4589 for &j in &dense {
4590 peeled[j] = true;
4591 }
4592
4593 // Conflict structure over the *non-peeled* columns only. Pairs with
4594 // a peeled endpoint are recovered from that endpoint's own pass, so
4595 // they neither need a color nor constrain one.
4596 let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4597 let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4598 for &(i, j) in lower_pairs {
4599 if peeled[i] || peeled[j] {
4600 continue;
4601 }
4602 col_rows[j].push(i as u32);
4603 row_cols[i].push(j as u32);
4604 if i != j {
4605 col_rows[i].push(j as u32);
4606 row_cols[j].push(i as u32);
4607 }
4608 }
4609
4610 let mut var_color = vec![u32::MAX; n];
4611 let mut forbidden = vec![u32::MAX; n + 1];
4612 let mut n_colors: u32 = 0;
4613
4614 for j in 0..n {
4615 // Peeled columns are colored below; a column with no surviving
4616 // Hessian entries needs no color at all.
4617 if peeled[j] || col_rows[j].is_empty() {
4618 continue;
4619 }
4620 // Mark colors used by any column sharing a row with `j`.
4621 // Row-of-col -> col-in-row visit pattern collects all
4622 // distance-1 neighbors in the column-intersection graph.
4623 for &r in &col_rows[j] {
4624 for &c in &row_cols[r as usize] {
4625 if c as usize == j {
4626 continue;
4627 }
4628 let cc = var_color[c as usize];
4629 if cc != u32::MAX {
4630 forbidden[cc as usize] = j as u32;
4631 }
4632 }
4633 }
4634 // First color not stamped with `j as u32`.
4635 let mut chosen: u32 = 0;
4636 while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4637 chosen += 1;
4638 }
4639 var_color[j] = chosen;
4640 if chosen + 1 > n_colors {
4641 n_colors = chosen + 1;
4642 }
4643 }
4644
4645 // One singleton color per peeled column, appended after the shared
4646 // ones so the non-peeled numbering is untouched.
4647 for &j in &dense {
4648 var_color[j] = n_colors;
4649 n_colors += 1;
4650 }
4651
4652 (var_color, n_colors as usize, peeled)
4653}
4654
4655/// Everything downstream of the Hessian coloring: seed vectors, the
4656/// per-color decode table, the per-tape color sets, and the shared-CSE
4657/// per-color summand / prelude-reach tables.
4658///
4659/// Split out of [`NlTnlp::new`] because
4660/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4661/// time, with a peel veto in hand, once it has seen real Hessian values.
4662fn build_color_tables(
4663 n: usize,
4664 m: usize,
4665 lower_pairs: &[(usize, usize)],
4666 tape_mask: &[bool],
4667 peel_veto: &[bool],
4668 obj_tapes: &[Tape],
4669 con_tapes: &[Vec<Tape>],
4670 con_hybrid: Option<&mut ConHybrid>,
4671) -> ColorTables {
4672 // Hessian column coloring. The chromatic number of the
4673 // column-intersection graph bounds how many directional
4674 // Hessian-vector products we need per `eval_h` call —
4675 // typically O(stencil) for PDE-mesh problems.
4676 //
4677 // Only the entries a *tape* can write take part. `tape_mask` is empty
4678 // when every entry is a tape entry (no quadratic structure), which is
4679 // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4680 // entries are excluded, since they are scattered from their stored
4681 // values and never read out of a directional product. A variable that
4682 // appears only in quadratic rows then has no color at all
4683 // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4684 // column with no surviving entries.
4685 let colored_pairs: Vec<(usize, usize)>;
4686 let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4687 lower_pairs
4688 } else {
4689 colored_pairs = lower_pairs
4690 .iter()
4691 .zip(tape_mask)
4692 .filter_map(|(p, &t)| t.then_some(*p))
4693 .collect();
4694 &colored_pairs
4695 };
4696 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4697
4698 // Per-color seed vectors (dense for O(1) Var lookup in
4699 // `Tape::hessian_directional`).
4700 let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4701 for (k, &c) in var_color.iter().enumerate() {
4702 if c != u32::MAX {
4703 seeds[c as usize][k] = 1.0;
4704 }
4705 }
4706
4707 // Per-color decoding table. For each lower-tri pair (i, j)
4708 // with i >= j, the entry belongs to column j's color: after
4709 // computing compressed_{c_j} = (H · s_{c_j}), the value at
4710 // row i is exactly H[i, j] (coloring guarantees no other
4711 // column in c_j has a nonzero at row i).
4712 // Built straight from `lower_pairs`, which is sorted, so each
4713 // color's table is in ascending `hess_idx` order and the decode
4714 // scatter walks `values` forward instead of hopping (the old
4715 // build drained a `HashMap`, whose iteration order is arbitrary).
4716 let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4717 for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4718 // An entry no tape writes has no pass to be decoded out of; the
4719 // quadratic scatter already put its value there.
4720 if !tape_mask.is_empty() && !tape_mask[idx] {
4721 continue;
4722 }
4723 // Which directional product recovers H[i, j]? Column `j`'s,
4724 // read at row `i` — except when `i` is a peeled column and
4725 // `j` is not: then `j` may have no color of its own, and the
4726 // entry is already in column `i`'s pass at row `j`, since
4727 // H[i, j] == H[j, i].
4728 let (c, row) = if peeled[i] && !peeled[j] {
4729 (var_color[i], j)
4730 } else {
4731 (var_color[j], i)
4732 };
4733 debug_assert!(
4734 c != u32::MAX,
4735 "Hessian pair ({i}, {j}) at index {idx} has no color"
4736 );
4737 decoding[c as usize].push(ColorWrite {
4738 row: row as u32,
4739 hess_idx: idx as u32,
4740 });
4741 }
4742
4743 // Per-tape distinct color set: for each tape, the colors
4744 // its variables fall into. `eval_h` loops over only these
4745 // (tape, color) pairs instead of n_tapes × n_colors.
4746 let tape_colors = |t: &Tape| -> Vec<u32> {
4747 let mut s: Vec<u32> = t
4748 .variables()
4749 .into_iter()
4750 .map(|v| var_color[v])
4751 .filter(|&c| c != u32::MAX)
4752 .collect();
4753 s.sort_unstable();
4754 s.dedup();
4755 s
4756 };
4757 let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4758 let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4759 .iter()
4760 .map(|row| row.iter().map(tape_colors).collect())
4761 .collect();
4762
4763 // Shared-CSE Hessian tables (issue #557): the per-color summand
4764 // lists, row lookup, and packed forward-value arena `eval_h`'s
4765 // hybrid path walks. Built whenever the hybrid tape is — not just
4766 // above the gate — so flipping `use_for_hess` on (tests, the
4767 // force env var) needs no extra setup; the cost is one f64 per
4768 // local op plus small index tables.
4769 if let Some(h) = con_hybrid {
4770 let n_sum = h.tape.n_summands();
4771 let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4772 let mut acc = 0usize;
4773 for s in &h.tape.summands {
4774 local_off.push(acc);
4775 acc += s.ops.len();
4776 }
4777 local_off.push(acc);
4778 h.local_vals_all = vec![0.0; acc];
4779 h.local_off = local_off;
4780
4781 let mut summand_row = vec![0u32; n_sum];
4782 for i in 0..m {
4783 for si in h.row_start[i]..h.row_start[i + 1] {
4784 summand_row[si] = i as u32;
4785 }
4786 }
4787 h.summand_row = summand_row;
4788
4789 // A summand's variable set (`all_vars`) equals its flat tape's,
4790 // so this is `con_tape_colors` inverted to color-major order —
4791 // the loop `eval_h` actually runs.
4792 let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4793 for (si, s) in h.tape.summands.iter().enumerate() {
4794 let mut cs: Vec<u32> = s
4795 .all_vars
4796 .iter()
4797 .map(|&v| var_color[v])
4798 .filter(|&c| c != u32::MAX)
4799 .collect();
4800 cs.sort_unstable();
4801 cs.dedup();
4802 for c in cs {
4803 by_color[c as usize].push(si as u32);
4804 }
4805 }
4806 // Per-color prelude reach: the union of `prelude_reach` over the
4807 // color's summands, ascending. A union of operand-closed
4808 // ascending sets is itself operand-closed and ascending, which is
4809 // exactly what the two prelude sweeps require. Deduped with an
4810 // epoch-tagged buffer so the build costs
4811 // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4812 // it saves — rather than `n_colors × |prelude|`.
4813 let np = h.tape.n_prelude_ops();
4814 let mut seen: Vec<u32> = vec![0; np];
4815 let mut epoch: u32 = 0;
4816 let mut reach: Vec<u32> = Vec::new();
4817 let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4818 for list in &by_color {
4819 reach_off.push(reach.len());
4820 epoch += 1;
4821 let start = reach.len();
4822 for &si in list {
4823 for &p in &h.tape.summands[si as usize].prelude_reach {
4824 if seen[p] != epoch {
4825 seen[p] = epoch;
4826 reach.push(p as u32);
4827 }
4828 }
4829 }
4830 reach[start..].sort_unstable();
4831 }
4832 reach_off.push(reach.len());
4833 h.hess_color_reach = reach;
4834 h.hess_color_reach_off = reach_off;
4835
4836 h.hess_color_summands = by_color;
4837 h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4838 h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4839 h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4840 h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4841 h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4842 }
4843
4844 ColorTables {
4845 var_color,
4846 n_colors,
4847 peeled_cols: peeled
4848 .iter()
4849 .enumerate()
4850 .filter(|(_, p)| **p)
4851 .map(|(j, _)| j as u32)
4852 .collect(),
4853 seeds,
4854 decoding,
4855 hess_color_index: HessColorIndex::build(n_colors, &con_tape_colors),
4856 obj_tape_colors,
4857 con_tape_colors,
4858 }
4859}
4860
4861/// The color-dependent half of an [`NlTnlp`], as built by
4862/// [`build_color_tables`].
4863struct ColorTables {
4864 var_color: Vec<u32>,
4865 n_colors: usize,
4866 /// Columns given a singleton color and dropped from the conflict
4867 /// structure. Small by construction (`MAX_PEELED_COLS`).
4868 peeled_cols: Vec<u32>,
4869 seeds: Vec<Vec<f64>>,
4870 decoding: Vec<Vec<ColorWrite>>,
4871 obj_tape_colors: Vec<Vec<u32>>,
4872 con_tape_colors: Vec<Vec<Vec<u32>>>,
4873 /// `con_tape_colors` inverted to color-major, CSR-packed: the (row, tape)
4874 /// pairs whose tape touches each color, and the objective tapes likewise.
4875 ///
4876 /// The flat `eval_h` walks tapes and, inside each, the colors it touches,
4877 /// so several rows accumulate into one `compressed[c]` — fine serially,
4878 /// unshareable across threads. Walking color-major makes each color's
4879 /// accumulator private to its task, at the cost of forwarding a tape once
4880 /// per color it touches instead of once (measured at 2.9 colors per tape
4881 /// on an AC-SCOPF model, against a directional sweep that costs several
4882 /// forwards, so ~20% more serial work for 28-way parallelism).
4883 ///
4884 /// Within a color the pairs stay in ascending (row, tape) order, which is
4885 /// the order the tape-major loop contributes them in, so the two walks
4886 /// accumulate identical sums in identical order — bit-for-bit, not merely
4887 /// within tolerance. `parallel_matches_serial_bit_for_bit` pins that.
4888 hess_color_index: HessColorIndex,
4889}
4890
4891/// Color-major (row, tape) work lists for the flat Hessian path. See
4892/// [`ColorTables::hess_color_index`].
4893#[derive(Debug, Default, Clone)]
4894pub(crate) struct HessColorIndex {
4895 /// Constraint (row, tape) pairs per color, CSR. The objective's tapes are
4896 /// deliberately absent: `eval_h` accumulates them into `compressed`
4897 /// *before* the constraint block and keeps doing so serially, which is
4898 /// what makes the parallel walk's per-color ordering identical to the
4899 /// serial one — objective first, then rows ascending.
4900 con_off: Vec<u32>,
4901 con_row: Vec<u32>,
4902 con_tape: Vec<u32>,
4903}
4904
4905impl HessColorIndex {
4906 fn build(n_colors: usize, con_tape_colors: &[Vec<Vec<u32>>]) -> Self {
4907 // Rows ascending, then tapes ascending: the tape-major loop's order.
4908 let mut con: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_colors];
4909 for (k, row) in con_tape_colors.iter().enumerate() {
4910 for (ti, colors) in row.iter().enumerate() {
4911 for &c in colors {
4912 con[c as usize].push((k as u32, ti as u32));
4913 }
4914 }
4915 }
4916 let mut out = HessColorIndex {
4917 con_off: Vec::with_capacity(n_colors + 1),
4918 ..Default::default()
4919 };
4920 out.con_off.push(0);
4921 for list in &con {
4922 for &(k, ti) in list {
4923 out.con_row.push(k);
4924 out.con_tape.push(ti);
4925 }
4926 out.con_off.push(out.con_row.len() as u32);
4927 }
4928 out
4929 }
4930
4931 /// The (row, tape) pairs of color `c`, as two parallel slices.
4932 fn con_pairs(&self, c: usize) -> (&[u32], &[u32]) {
4933 let (a, b) = (self.con_off[c] as usize, self.con_off[c + 1] as usize);
4934 (&self.con_row[a..b], &self.con_tape[a..b])
4935 }
4936}
4937
4938impl NlTnlp {
4939 /// Build the TNLP, panicking if AMPL external-function resolution fails.
4940 ///
4941 /// Kept for the many infallible call sites (tests) that operate on `.nl`
4942 /// models known to need no external libraries. Surfaces that can be
4943 /// handed an arbitrary user model — the CLI solve and `verify` paths, the
4944 /// Python `read_nl` binding — must call [`Self::try_new`] instead so a
4945 /// missing `$AMPLFUNC` library becomes an ordinary error rather than a
4946 /// panic (gh#1069: the CLI exited 101 with a backtrace hint).
4947 pub fn new(prob: NlProblem) -> Self {
4948 Self::try_new(prob)
4949 .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4950 }
4951
4952 /// Build the TNLP, returning an error (instead of panicking) when AMPL
4953 /// imported functions named by the model can't be resolved — e.g.
4954 /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4955 /// referenced function id isn't registered by any loaded library.
4956 pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4957 // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4958 // objective — the A/B reference for the constant-structure path,
4959 // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4960 // fast path's derivatives are checked against the ones they replace
4961 // on a real model, and how a suspected fast-path bug is bisected
4962 // against a reference that computes the same numbers a different way.
4963 Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4964 }
4965
4966 /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4967 /// explicitly on or off.
4968 ///
4969 /// The env var `try_new` reads is process-global, which is exactly wrong
4970 /// for the differential test that has to build the *same* model both ways
4971 /// and compare the derivatives — so the knob is a parameter here and the
4972 /// env var only chooses its default.
4973 pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4974 // Resolve any AMPL imported (external) functions. Walk every
4975 // nonlinear expression to collect the funcall ids actually
4976 // referenced; load the libraries named in $AMPLFUNC and bind
4977 // each id to its (library, registered-name) pair so the tape
4978 // builder can emit live `TapeOp::Funcall` ops.
4979 let mut referenced: BTreeSet<usize> = BTreeSet::new();
4980 // Only trees can carry a `Funcall`: the recognizer refuses one, so
4981 // a recognized body provably contains none.
4982 for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4983 if let Some(e) = body.tree() {
4984 super::nl_external::collect_funcall_ids(e, &mut referenced);
4985 }
4986 }
4987 let resolver = if referenced.is_empty() {
4988 super::nl_external::ExternalResolver::default()
4989 } else {
4990 super::nl_external::ExternalResolver::build_for_problem(
4991 &prob.imported_funcs,
4992 &referenced,
4993 )?
4994 };
4995
4996 // Recognize the degree-≤2 objective and rows *before* anything is
4997 // taped, because the win is not in evaluating the tape faster — it
4998 // is in never building it. On `qcqp500-3c` the ten quadratic rows
4999 // are 2.32 M monomials, one `Tape` each; recognizing them first
5000 // means those 2.32 M tapes, their color lists and their per-tape
5001 // sparsity sets are never allocated.
5002 //
5003 // A row that is trivially zero is left alone: it has no nonlinear
5004 // part to replace, and routing it through a (necessarily empty)
5005 // quadratic form would touch every model in the corpus to save
5006 // nothing.
5007 let mut quad = QuadraticStructure::new(prob.m);
5008 if use_quadratic {
5009 // `is_expanded_quadratic` is the accuracy gate, not an
5010 // optimization: it admits only forms whose read-out repeats the
5011 // additions the `.nl` writer already wrote. See its docs — and
5012 // note it is checked *before* recognition, so a factored form
5013 // costs one cheap structural walk rather than a full expansion.
5014 // Recognition is the parser's job now (gh #588, Q5) for the
5015 // bodies it could reach; `admitted_quad_form` reads its answer
5016 // back, and still walks a tree for the bodies that kept one —
5017 // a `from_expressions` model, a factored row rewound by the
5018 // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
5019 //
5020 // A form that gate refuses is offered the *factored* read-out
5021 // before it falls back to a tape (gh #673): the gate is a
5022 // verdict on the expansion, not on the body, and a sum of
5023 // squared residuals keeps its structure by keeping its squares.
5024 // `push_body_form` owns that order.
5025 if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
5026 quad.assign_objective(f);
5027 }
5028 for k in 0..prob.m {
5029 if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
5030 quad.assign_row(k, f);
5031 }
5032 }
5033 }
5034
5035 // Flatten objective and each constraint into independent
5036 // summands. Each summand becomes its own `Tape` (CSE bodies
5037 // are deduplicated within a tape via Rc identity in
5038 // `Tape::build`; bodies shared across summands are
5039 // duplicated, which we accept as a simplicity tradeoff).
5040 let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
5041 Vec::new()
5042 } else {
5043 split_top_sums(&prob.obj_expr())
5044 .iter()
5045 .map(|e| Tape::build_with_externals(e, &resolver))
5046 .collect()
5047 };
5048
5049 let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
5050 let mut con_roots: Vec<Expr> = Vec::new();
5051 let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
5052 for k in 0..prob.m {
5053 row_start.push(con_roots.len());
5054 // A row with a quadratic form contributes no tape and no
5055 // hybrid-tape root, so `con_tapes[k]` is empty and its
5056 // `row_start` range is empty too.
5057 if quad.row_form(k).is_some() {
5058 con_tapes.push(Vec::new());
5059 continue;
5060 }
5061 let summands = split_top_sums(&prob.con_expr(k));
5062 con_tapes.push(
5063 summands
5064 .iter()
5065 .map(|e| Tape::build_with_externals(e, &resolver))
5066 .collect(),
5067 );
5068 // Move (not clone) the split summands into the root list: their
5069 // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
5070 // sharing on `Arc` pointer identity, so the roots must be the
5071 // same allocations the parse produced.
5072 con_roots.extend(summands);
5073 }
5074 row_start.push(con_roots.len());
5075
5076 // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
5077 // building only when some CSE body is actually referenced from two
5078 // or more summands — otherwise the prelude comes out empty and the
5079 // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
5080 // gates the opcodes `build_multi` would panic on.
5081 // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
5082 // whole constraint block. Diagnostic only: it is how the
5083 // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
5084 // on a real model, and how a suspected hybrid-path bug is bisected
5085 // against a reference that computes the same derivatives a
5086 // different way.
5087 let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
5088 None
5089 } else if hybrid_supported(&con_roots) {
5090 let tape = HybridTape::build_multi(&con_roots);
5091 (tape.n_prelude_ops() > 0).then(|| {
5092 let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5093 let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
5094 // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
5095 // regardless of the op ratio. Diagnostic only — it is how the
5096 // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
5097 // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
5098 // models that sit below the gate.
5099 let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
5100 ConHybrid {
5101 prelude_vals: vec![0.0; tape.n_prelude_ops()],
5102 local_vals: vec![0.0; tape.max_summand_ops()],
5103 local_adj: vec![0.0; tape.max_summand_ops()],
5104 prelude_adj: vec![0.0; tape.n_prelude_ops()],
5105 use_for_jac: flat_ops as f64
5106 >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
5107 use_for_hess: force_hess
5108 || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
5109 local_vals_all: Vec::new(),
5110 local_off: Vec::new(),
5111 summand_row: Vec::new(),
5112 hess_color_summands: Vec::new(),
5113 hess_color_reach: Vec::new(),
5114 hess_color_reach_off: Vec::new(),
5115 prelude_dot: Vec::new(),
5116 hess_prelude_adj: Vec::new(),
5117 prelude_adj_dot: Vec::new(),
5118 local_dot: Vec::new(),
5119 local_adj_dot: Vec::new(),
5120 row_start,
5121 tape,
5122 }
5123 })
5124 } else {
5125 None
5126 };
5127 drop(con_roots);
5128
5129 // Hessian-of-Lagrangian sparsity: union of each tape's own
5130 // structural Hessian sparsity.
5131 // One flat `Vec`, sorted and deduped once, rather than a global
5132 // `BTreeSet` fed a single insert at a time across every summand
5133 // in the model: sort+dedup walks contiguous memory where the tree
5134 // chased a pointer and allocated a node per entry. The result is
5135 // exactly the ascending order the rest of this function wants, so
5136 // it doubles as `lower_pairs` instead of being copied into it.
5137 let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
5138 for t in &obj_tapes {
5139 tape_pairs.extend(t.hessian_sparsity());
5140 }
5141 for row in &con_tapes {
5142 for t in row {
5143 tape_pairs.extend(t.hessian_sparsity());
5144 }
5145 }
5146 tape_pairs.sort_unstable();
5147 tape_pairs.dedup();
5148
5149 // The assembled pattern is the tapes' union *plus* the quadratic
5150 // forms'. They are kept apart because the coloring only ever needs
5151 // the tape half — a quadratic block's entries are scattered
5152 // directly, not recovered from a directional product — and coloring
5153 // a dense quadratic block would put the color count back at `n` to
5154 // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
5155 let mut lower_pairs = tape_pairs.clone();
5156 if !quad.is_empty() {
5157 for f in quad
5158 .objective_form()
5159 .into_iter()
5160 .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
5161 {
5162 lower_pairs.extend(
5163 quad.lower_triangle(f)
5164 .map(|(r, c, _)| (r as usize, c as usize)),
5165 );
5166 }
5167 lower_pairs.sort_unstable();
5168 lower_pairs.dedup();
5169 }
5170
5171 // Which assembled entries a tape can write. Both sides are sorted
5172 // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
5173 // mask is left empty (meaning "all of them") when nothing was
5174 // recognized.
5175 let h_tape_mask: Vec<bool> = if quad.is_empty() {
5176 Vec::new()
5177 } else {
5178 let mut mask = vec![false; lower_pairs.len()];
5179 let mut t = 0usize;
5180 for (idx, pair) in lower_pairs.iter().enumerate() {
5181 if t < tape_pairs.len() && tape_pairs[t] == *pair {
5182 mask[idx] = true;
5183 t += 1;
5184 }
5185 }
5186 debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
5187 mask
5188 };
5189 drop(tape_pairs);
5190
5191 let mut h_irow = Vec::with_capacity(lower_pairs.len());
5192 let mut h_jcol = Vec::with_capacity(lower_pairs.len());
5193 for &(hi, lo) in &lower_pairs {
5194 h_irow.push(hi as i32);
5195 h_jcol.push(lo as i32);
5196 }
5197
5198 // Bind each form's lower-triangle entries to their index in the
5199 // assembled pattern. `lower_pairs` is sorted, so the lookup is a
5200 // binary search — done once here, never again on the hot path.
5201 if !quad.is_empty() {
5202 quad.bind_slots(|r, c| {
5203 lower_pairs
5204 .binary_search(&(r as usize, c as usize))
5205 .unwrap_or_else(|_| {
5206 unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
5207 })
5208 });
5209 }
5210
5211 // Hessian column coloring and everything keyed off it. The
5212 // chromatic number of the column-intersection graph bounds how
5213 // many directional Hessian-vector products we need per `eval_h`
5214 // call — typically O(stencil) for PDE-mesh problems.
5215 let ColorTables {
5216 var_color,
5217 n_colors,
5218 peeled_cols,
5219 seeds,
5220 decoding,
5221 obj_tape_colors,
5222 con_tape_colors,
5223 hess_color_index,
5224 } = build_color_tables(
5225 prob.n,
5226 prob.m,
5227 &lower_pairs,
5228 &h_tape_mask,
5229 &vec![false; prob.n],
5230 &obj_tapes,
5231 &con_tapes,
5232 con_hybrid.as_mut(),
5233 );
5234
5235 // Per-row Jacobian sparsity = union of tape vars plus
5236 // linear-segment vars.
5237 let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
5238 let mut jac_nnz = 0;
5239 for (i, row_tapes) in con_tapes.iter().enumerate() {
5240 let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
5241 for t in row_tapes {
5242 cols.extend(t.variables());
5243 }
5244 // A quadratic row has no tape, so its Jacobian support comes
5245 // from the form: `Hx + a` is nonzero exactly on the union of the
5246 // Hessian's rows and the folded linear part.
5247 if let Some(f) = quad.row_form(i) {
5248 cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
5249 }
5250 cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
5251 cols.sort_unstable();
5252 cols.dedup();
5253 cols.shrink_to_fit();
5254 jac_nnz += cols.len();
5255 jac_cols.push(cols);
5256 }
5257
5258 let mut max_tape_n: usize = 0;
5259 for t in &obj_tapes {
5260 max_tape_n = max_tape_n.max(t.ops.len());
5261 }
5262 for row in &con_tapes {
5263 for t in row {
5264 max_tape_n = max_tape_n.max(t.ops.len());
5265 }
5266 }
5267
5268 if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
5269 // Mean number of Hessian colors a single constraint tape touches.
5270 // It is the cost multiplier of visiting the work color-major
5271 // rather than tape-major: each (tape, color) pair needs the
5272 // tape's forward values, so a color-major walk forwards each tape
5273 // this many times instead of once.
5274 let (mut tape_color_pairs, mut n_live_tapes) = (0usize, 0usize);
5275 for row in &con_tape_colors {
5276 for cols in row {
5277 if !cols.is_empty() {
5278 tape_color_pairs += cols.len();
5279 n_live_tapes += 1;
5280 }
5281 }
5282 }
5283 let colors_per_tape = tape_color_pairs as f64 / n_live_tapes.max(1) as f64;
5284 let n_obj = obj_tapes.len();
5285 let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
5286 let total = n_obj + n_con;
5287 let mut sum_ops: usize = 0;
5288 for t in &obj_tapes {
5289 sum_ops += t.ops.len();
5290 }
5291 for row in &con_tapes {
5292 for t in row {
5293 sum_ops += t.ops.len();
5294 }
5295 }
5296 let t = total.max(1);
5297 let nnz_h = h_irow.len();
5298 let avg_decode =
5299 decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
5300 eprintln!(
5301 "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
5302 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
5303 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h} \
5304 colors_per_tape={colors_per_tape:.2}",
5305 sum_ops as f64 / t as f64,
5306 );
5307 // Flat vs shared-CSE op counts for the constraint block. The
5308 // ratio is how much duplicated CSE work `eval_g`'s hybrid path
5309 // avoids, and the ceiling on what routing the Jacobian /
5310 // Hessian through the same prelude could save.
5311 match &con_hybrid {
5312 Some(h) => {
5313 let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5314 let prelude = h.tape.n_prelude_ops();
5315 let local = h.tape.total_local_ops();
5316 eprintln!(
5317 "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
5318 local_ops={local} shared_total={} flat/shared={:.2}x \
5319 jac_gate={} hess_gate={}",
5320 prelude + local,
5321 flat as f64 / (prelude + local).max(1) as f64,
5322 if h.use_for_jac { "on" } else { "off" },
5323 if h.use_for_hess { "on" } else { "off" },
5324 );
5325 }
5326 None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
5327 }
5328 // What the constant-structure path took off the tape builder.
5329 // `forms` counts the objective too, which is why it can exceed
5330 // the row count by one.
5331 let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
5332 // How many of those forms the *parser* produced, i.e. how many
5333 // never cost an `Expr` (gh #588, Q5). The rest were recognized
5334 // from a tree that was built: a `from_expressions` model, or a
5335 // body the parser rewound because it was not degree 2.
5336 let parsed = std::iter::once(&prob.obj_nonlinear)
5337 .chain(prob.con_nonlinear.iter())
5338 .filter(|b| b.quad().is_some())
5339 .count();
5340 eprintln!(
5341 "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
5342 stored_h_entries={} colored_pairs={}/{}",
5343 quad.len(),
5344 prob.m,
5345 if quad.objective_form().is_some() {
5346 "quadratic"
5347 } else {
5348 "taped"
5349 },
5350 quad.stored_entries(),
5351 if h_tape_mask.is_empty() {
5352 lower_pairs.len()
5353 } else {
5354 h_tape_mask.iter().filter(|&&t| t).count()
5355 },
5356 lower_pairs.len(),
5357 );
5358 }
5359
5360 let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
5361
5362 let mut me = Self {
5363 prob,
5364 obj_tapes,
5365 con_tapes,
5366 con_hybrid,
5367 quad,
5368 h_tape_mask,
5369 h_irow,
5370 h_jcol,
5371 jac_cols,
5372 jac_nnz,
5373 seeds,
5374 decoding,
5375 obj_tape_colors,
5376 con_tape_colors,
5377 hess_color_index,
5378 jac_row_off: Vec::new(),
5379 #[cfg(not(target_arch = "wasm32"))]
5380 jac_scratch_pool: Vec::new(),
5381 var_color,
5382 peeled_cols,
5383 final_x: None,
5384 final_obj: 0.0,
5385 final_lambda: None,
5386 final_z_l: None,
5387 final_z_u: None,
5388 scratch_row_grad: Vec::new(),
5389 vals_scratch: vec![0.0; max_tape_n],
5390 dot_scratch: vec![0.0; max_tape_n],
5391 adj_scratch: vec![0.0; max_tape_n],
5392 adj_dot_scratch: vec![0.0; max_tape_n],
5393 compressed,
5394 hvp_live: Vec::new(),
5395 curvature_scaling: None,
5396 };
5397 me.veto_ill_conditioned_peels();
5398 Ok(me)
5399 }
5400
5401 /// Un-peel any dense column whose own pass is too ill-scaled to read
5402 /// its small entries out of, and re-color if that changes anything.
5403 ///
5404 /// `greedy_hessian_coloring` picks the peel set from structure alone,
5405 /// which is the right call for the memory and the color count but
5406 /// blind to the one thing that can go wrong: an entry recovered from
5407 /// column `d`'s pass inherits that pass's roundoff floor, about
5408 /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
5409 /// well-scaled dense column loses nothing to that — the recovered
5410 /// entries come back bit-identical to the uncompressed reference on
5411 /// every peel-firing model in the benchmark corpus but one. A column
5412 /// spanning many orders of magnitude, though, hands its small entries
5413 /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
5414 /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
5415 /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
5416 /// survives that, but the multipliers come out of the KKT system the
5417 /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
5418 /// the solve stalls short of `Optimal` on a problem it used to
5419 /// certify.
5420 ///
5421 /// Nothing structural distinguishes the two cases, so measure it: one
5422 /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
5423 /// column's exact pass sitting in `compressed`, and a column whose
5424 /// worst recovered entry would lose more than
5425 /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
5426 /// one `eval_h` — at the peeled color count, so cheap — and only for
5427 /// the ~3% of models that peel anything at all.
5428 fn veto_ill_conditioned_peels(&mut self) {
5429 if self.peeled_cols.is_empty() {
5430 return;
5431 }
5432
5433 let mut values = vec![0.0; self.h_irow.len()];
5434 let lambda = vec![1.0; self.prob.m];
5435 let x0 = self.prob.x0.clone();
5436 if !self.eval_h(
5437 Some(&x0),
5438 true,
5439 1.0,
5440 Some(&lambda),
5441 true,
5442 SparsityRequest::Values {
5443 values: &mut values,
5444 },
5445 ) {
5446 return;
5447 }
5448
5449 let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
5450 // A column whose whole pass is negligible against the Hessian as a
5451 // whole cannot move the KKT system no matter how badly its own
5452 // entries are rounded, and columns that are identically zero at
5453 // `x0` would otherwise veto on a ratio of pure noise.
5454 let h_scale = self
5455 .compressed
5456 .iter()
5457 .flat_map(|c| c.iter())
5458 .fold(0.0f64, |a, &v| a.max(v.abs()));
5459 let mut peel_veto = vec![false; self.prob.n];
5460 let mut vetoed = 0usize;
5461 for &d in &self.peeled_cols {
5462 let c = self.var_color[d as usize];
5463 if c == u32::MAX {
5464 continue;
5465 }
5466 let pass = &self.compressed[c as usize];
5467 // The floor the pass was accumulated at, against the smallest
5468 // entry actually read out of it.
5469 let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
5470 // Entries at or below the pass's own roundoff floor carry no
5471 // information to lose: `eps * scale` is the noise the pass was
5472 // accumulated at, so such an entry is already indistinguishable
5473 // from zero whether or not the column is peeled. Including them
5474 // would divide by that noise -- `orthregd` holds entries of
5475 // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
5476 // 4e-16 against an uncompressed reference.
5477 let noise = f64::EPSILON * scale;
5478 let smallest = self.decoding[c as usize]
5479 .iter()
5480 .map(|w| pass[w.row as usize].abs())
5481 .filter(|v| *v > noise)
5482 .fold(f64::INFINITY, f64::min);
5483 if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
5484 continue;
5485 }
5486 if scale <= h_scale * f64::EPSILON {
5487 continue;
5488 }
5489 let rel_err = f64::EPSILON * scale / smallest;
5490 if dbg {
5491 eprintln!(
5492 "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
5493 min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
5494 if rel_err > PEEL_MAX_REL_ERR {
5495 " VETO"
5496 } else {
5497 ""
5498 }
5499 );
5500 }
5501 if rel_err > PEEL_MAX_REL_ERR {
5502 peel_veto[d as usize] = true;
5503 vetoed += 1;
5504 }
5505 }
5506
5507 if vetoed == 0 {
5508 return;
5509 }
5510 if dbg {
5511 eprintln!(
5512 "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
5513 self.peeled_cols.len()
5514 );
5515 }
5516 self.recolor(&peel_veto);
5517 }
5518
5519 /// Rebuild the coloring and everything keyed off it, barring
5520 /// `peel_veto` from the peel set.
5521 fn recolor(&mut self, peel_veto: &[bool]) {
5522 let lower_pairs: Vec<(usize, usize)> = self
5523 .h_irow
5524 .iter()
5525 .zip(&self.h_jcol)
5526 .map(|(&i, &j)| (i as usize, j as usize))
5527 .collect();
5528 let ColorTables {
5529 var_color,
5530 n_colors,
5531 peeled_cols,
5532 seeds,
5533 decoding,
5534 obj_tape_colors,
5535 con_tape_colors,
5536 hess_color_index,
5537 } = build_color_tables(
5538 self.prob.n,
5539 self.prob.m,
5540 &lower_pairs,
5541 &self.h_tape_mask,
5542 peel_veto,
5543 &self.obj_tapes,
5544 &self.con_tapes,
5545 self.con_hybrid.as_mut(),
5546 );
5547 self.var_color = var_color;
5548 self.peeled_cols = peeled_cols;
5549 self.seeds = seeds;
5550 self.decoding = decoding;
5551 self.obj_tape_colors = obj_tape_colors;
5552 self.con_tape_colors = con_tape_colors;
5553 // Recoloring invalidates the color-major index: its entries name
5554 // colors, so a stale one would send a row's contribution to whatever
5555 // color now holds that slot. Refreshed here, not rebuilt lazily,
5556 // because nothing downstream can detect the staleness.
5557 self.hess_color_index = hess_color_index;
5558 self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
5559 }
5560
5561 pub fn final_x(&self) -> Option<&[Number]> {
5562 self.final_x.as_deref()
5563 }
5564
5565 pub fn final_obj(&self) -> Number {
5566 self.final_obj
5567 }
5568
5569 /// Converged constraint multipliers from the last solve, in original
5570 /// `.nl` row order. `None` before a solve finishes. See
5571 /// [`Self::final_x`] for the primal counterpart.
5572 pub fn final_lambda(&self) -> Option<&[Number]> {
5573 self.final_lambda.as_deref()
5574 }
5575
5576 /// Converged lower / upper bound multipliers from the last solve, in
5577 /// original `.nl` variable order and Ipopt's internal convention (both
5578 /// `>= 0`). `None` before a solve finishes.
5579 pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
5580 Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
5581 }
5582
5583 /// The parsed problem this TNLP evaluates (bounds, starting point,
5584 /// names, suffixes). Read-only; per-instance overrides go through
5585 /// [`Self::variant`].
5586 pub fn problem(&self) -> &NlProblem {
5587 &self.prob
5588 }
5589
5590 /// Opt this model in to **curvature-based** scaling (gh #703): compute
5591 /// the per-variable and per-row factors of
5592 /// [`crate::nl_scaling::curvature_scaling`] and serve them from
5593 /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
5594 /// them through the channel it already has for user factors.
5595 ///
5596 /// Returns `false` when the model is not one the scheme is defined for
5597 /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
5598 /// exists. The caller must surface that as an error rather than solving
5599 /// unscaled: an accepted scaling option that is then quietly not applied
5600 /// is exactly the gh #483 failure.
5601 ///
5602 /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
5603 /// passes over the magnitude surrogates, and nothing at all for a solve
5604 /// that never calls it.
5605 pub fn enable_curvature_scaling(&mut self) -> bool {
5606 match crate::nl_scaling::curvature_scaling(&self.prob) {
5607 Some(sc) => {
5608 self.curvature_scaling = Some(sc);
5609 true
5610 }
5611 None => false,
5612 }
5613 }
5614
5615 /// Whether [`Self::enable_curvature_scaling`] has been called and
5616 /// succeeded.
5617 pub fn curvature_scaling_enabled(&self) -> bool {
5618 self.curvature_scaling.is_some()
5619 }
5620
5621 /// Whether the enabled curvature scaling actually read any curvature —
5622 /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
5623 /// scaling is not enabled, and `false` for a degree-≤2 model whose every
5624 /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
5625 /// equilibration of `[A b]`.
5626 pub fn curvature_scaling_read_curvature(&self) -> bool {
5627 self.curvature_scaling
5628 .as_ref()
5629 .is_some_and(|sc| sc.quadratic)
5630 }
5631
5632 /// Is constraint row `i` evaluated from a constant quadratic form
5633 /// rather than from an AD tape (gh #588, Q4)?
5634 ///
5635 /// Structural, so it answers before any evaluation. Exposed for the
5636 /// differential test, which has to know which models exercise the fast
5637 /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
5638 pub fn quadratic_row(&self, i: usize) -> bool {
5639 self.quad.row_form(i).is_some()
5640 }
5641
5642 /// As [`Self::quadratic_row`], for the objective.
5643 pub fn quadratic_objective(&self) -> bool {
5644 self.quad.objective_form().is_some()
5645 }
5646
5647 /// Structural set of variables that appear in *some* nonlinear part —
5648 /// the union of `collect_vars` over the objective and every constraint
5649 /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5650 /// list below so the two can never disagree.
5651 fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5652 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5653 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5654 for row in &self.prob.con_nonlinear {
5655 row.collect_vars(&mut nonlinear);
5656 }
5657 nonlinear
5658 }
5659
5660 /// The nonlinear-variable list published through the TNLP contract
5661 /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5662 /// ascending, in the C index style [`NlTnlp`] reports.
5663 ///
5664 /// The contract's asymmetry decides how this is computed. A consumer
5665 /// treats every variable *absent* from the list as linear — Ipopt's
5666 /// limited-memory Hessian skips the quasi-Newton update in that
5667 /// subspace — so naming too few variables is a wrong answer, while
5668 /// naming too many merely costs work. Hence:
5669 ///
5670 /// * When the `.nl` header says **every** variable is nonlinear, publish
5671 /// that and skip the walk. This is the maximally conservative answer,
5672 /// so it is sound whatever the header's provenance, and it is the
5673 /// common case for the models this matters on (`eigena2`: 110 of 110).
5674 /// * Otherwise walk the trees. The header's prefix
5675 /// (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5676 /// answer, but it would be one that *trusts* the writer to have
5677 /// ordered the variables as the format requires, and the walk is
5678 /// already paid once per solve by `get_variables_linearity`. The
5679 /// walk is also the only option for a model built through
5680 /// [`NlProblem::from_expressions`], which has no header at all.
5681 ///
5682 /// The two disagree only in the safe direction, which
5683 /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5684 /// fixture corpus: the walked set always sits inside the header's
5685 /// prefix, because `parse_nl_text` folds constant `C` bodies away
5686 /// (`gh #492`) and AMPL's own census predates that fold.
5687 fn nonlinear_variables(&self) -> Vec<Index> {
5688 if let Some(c) = self.prob.nl_counts
5689 && c.nonlinear_vars() >= self.prob.n
5690 {
5691 return (0..self.prob.n as Index).collect();
5692 }
5693 self.nonlinear_var_set()
5694 .into_iter()
5695 .map(|i| i as Index)
5696 .collect()
5697 }
5698
5699 /// Mutable access to that same problem, for a caller that owns this
5700 /// TNLP outright.
5701 ///
5702 /// The tapes were built from the expressions in [`Self::problem`] and
5703 /// are not rebuilt, so editing an expression here does **not** change
5704 /// what this TNLP evaluates. It exists for teardown: the Python
5705 /// binding takes the expression trees out through here so a deeply
5706 /// nested one is dropped on a stack chosen for it rather than
5707 /// recursively on whatever thread collected the object (pounce#472).
5708 pub fn problem_mut(&mut self) -> &mut NlProblem {
5709 &mut self.prob
5710 }
5711
5712 /// Hessian-vector product of the Lagrangian:
5713 /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5714 ///
5715 /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5716 /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5717 /// compressed columns into the sparse lower triangle; here the seed is
5718 /// the caller's `v` directly, so it is a single forward-over-reverse
5719 /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5720 /// chromatic number. That is what makes it usable on models where
5721 /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5722 /// truncated-CG step only ever needs `∇²L · v`.
5723 ///
5724 /// Sign convention matches `eval_h` and the rest of this evaluator: a
5725 /// `maximize` model's objective is negated so the returned operator is
5726 /// the one that minimizing solves. `lambda` is `None` for the objective
5727 /// block alone.
5728 ///
5729 /// `out` is overwritten (not accumulated into). Errors on any length
5730 /// mismatch rather than panicking, since the Python binding hands this
5731 /// arbitrary user arrays.
5732 pub fn hessian_vector_product(
5733 &mut self,
5734 x: &[Number],
5735 v: &[Number],
5736 obj_factor: Number,
5737 lambda: Option<&[Number]>,
5738 out: &mut [Number],
5739 ) -> Result<(), String> {
5740 self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5741 }
5742
5743 /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5744 /// once, `out[:, c] = ∇²L · v[:, c]`.
5745 ///
5746 /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5747 /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5748 ///
5749 /// Worth having as its own entry point rather than a loop over the
5750 /// single-vector call: the forward sweep depends only on `x`, so a block
5751 /// runs it *once per tape* and reuses `vals` across all `k` directions,
5752 /// where `k` separate calls would redo it `k` times. Only the
5753 /// forward-tangent + reverse-over-tangent passes are per-direction. That
5754 /// is the shape a block-Krylov solve, a directional-derivative probe, or
5755 /// a densify-the-Hessian loop wants.
5756 ///
5757 /// An all-zero direction is skipped, so passing a sparse block whose
5758 /// columns are mostly empty costs only the columns that carry signal.
5759 /// (The sparsity that dominates is the model's own: each tape touches
5760 /// only its own variables, and `hessian_directional` is O(tape ops), not
5761 /// O(n).)
5762 pub fn hessian_vector_products(
5763 &mut self,
5764 x: &[Number],
5765 v: &[Number],
5766 k: usize,
5767 obj_factor: Number,
5768 lambda: Option<&[Number]>,
5769 out: &mut [Number],
5770 ) -> Result<(), String> {
5771 let (n, m) = (self.prob.n, self.prob.m);
5772 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5773 if got == want {
5774 Ok(())
5775 } else {
5776 Err(format!(
5777 "hessian_vector_product: {name} has length {got}, expected {want}"
5778 ))
5779 }
5780 };
5781 check("x", x.len(), n)?;
5782 check("v", v.len(), n * k)?;
5783 check("out", out.len(), n * k)?;
5784 if let Some(lam) = lambda {
5785 check("lambda", lam.len(), m)?;
5786 }
5787
5788 out.fill(0.0);
5789 if k == 0 || n == 0 {
5790 return Ok(());
5791 }
5792
5793 // Which directions carry signal. Computed once, not per (tape,
5794 // direction) pair — with many small summand tapes the scan would
5795 // otherwise dominate the work it is meant to save. Reuses the
5796 // persistent mask so a Krylov loop allocates nothing per iteration.
5797 self.hvp_live.clear();
5798 self.hvp_live
5799 .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5800 if !self.hvp_live.iter().any(|&l| l) {
5801 return Ok(());
5802 }
5803
5804 let obj_seed = if self.prob.minimize {
5805 obj_factor
5806 } else {
5807 -obj_factor
5808 };
5809 // The constant blocks. `H · v` for a quadratic form is a matvec
5810 // against stored values, so unlike a tape it needs no forward sweep
5811 // and no `x` at all. Easy to forget — a matrix-free solve that
5812 // silently dropped the quadratic part of `∇²L` would still converge,
5813 // just to a different point by a different route, which is the
5814 // failure mode this series is most exposed to.
5815 if !self.quad.is_empty() {
5816 if obj_seed != 0.0 {
5817 if let Some(f) = self.quad.objective_form() {
5818 for (c, out_col) in out.chunks_mut(n).enumerate() {
5819 if self.hvp_live[c] {
5820 self.quad.add_hessian_vector(
5821 f,
5822 &v[c * n..(c + 1) * n],
5823 obj_seed,
5824 out_col,
5825 );
5826 }
5827 }
5828 }
5829 }
5830 if let Some(lam) = lambda {
5831 for (i, &w) in lam.iter().enumerate() {
5832 if w == 0.0 {
5833 continue;
5834 }
5835 let Some(f) = self.quad.row_form(i) else {
5836 continue;
5837 };
5838 for (c, out_col) in out.chunks_mut(n).enumerate() {
5839 if self.hvp_live[c] {
5840 self.quad
5841 .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5842 }
5843 }
5844 }
5845 }
5846 }
5847
5848 if obj_seed != 0.0 {
5849 for t in &self.obj_tapes {
5850 if t.ops.is_empty() {
5851 continue;
5852 }
5853 // Once per tape, not once per direction — the whole point
5854 // of the block form.
5855 t.forward_into(x, &mut self.vals_scratch);
5856 for (c, out_col) in out.chunks_mut(n).enumerate() {
5857 if !self.hvp_live[c] {
5858 continue;
5859 }
5860 t.hessian_directional(
5861 &self.vals_scratch,
5862 &v[c * n..(c + 1) * n],
5863 obj_seed,
5864 out_col,
5865 &mut self.dot_scratch,
5866 &mut self.adj_scratch,
5867 &mut self.adj_dot_scratch,
5868 );
5869 }
5870 }
5871 }
5872
5873 if let Some(lam) = lambda {
5874 // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5875 // range for every k.
5876 for (i, &w) in lam.iter().enumerate() {
5877 if w == 0.0 {
5878 continue;
5879 }
5880 for t in &self.con_tapes[i] {
5881 if t.ops.is_empty() {
5882 continue;
5883 }
5884 t.forward_into(x, &mut self.vals_scratch);
5885 for (c, out_col) in out.chunks_mut(n).enumerate() {
5886 if !self.hvp_live[c] {
5887 continue;
5888 }
5889 t.hessian_directional(
5890 &self.vals_scratch,
5891 &v[c * n..(c + 1) * n],
5892 w,
5893 out_col,
5894 &mut self.dot_scratch,
5895 &mut self.adj_scratch,
5896 &mut self.adj_dot_scratch,
5897 );
5898 }
5899 }
5900 }
5901 }
5902
5903 Ok(())
5904 }
5905
5906 /// Clone this TNLP with per-instance overrides applied — the
5907 /// "one structure, many bound / starting-point variations" case of
5908 /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5909 /// coloring are reused via `Clone` (they depend only on the model
5910 /// structure, which a variation cannot change); only the values in
5911 /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5912 /// are replaced. Any stale `final_x` from a previous solve of
5913 /// `self` is cleared on the clone.
5914 ///
5915 /// Errors when an override's length does not match the model
5916 /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5917 pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5918 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5919 if got == want {
5920 Ok(())
5921 } else {
5922 Err(format!(
5923 "NlVariation.{name} has length {got}, expected {want}"
5924 ))
5925 }
5926 };
5927 let mut out = self.clone();
5928 out.final_x = None;
5929 out.final_obj = 0.0;
5930 out.final_lambda = None;
5931 out.final_z_l = None;
5932 out.final_z_u = None;
5933 if let Some(x0) = &v.x0 {
5934 check("x0", x0.len(), self.prob.n)?;
5935 out.prob.x0.clone_from(x0);
5936 }
5937 if let Some(x_l) = &v.x_l {
5938 check("x_l", x_l.len(), self.prob.n)?;
5939 out.prob.x_l.clone_from(x_l);
5940 }
5941 if let Some(x_u) = &v.x_u {
5942 check("x_u", x_u.len(), self.prob.n)?;
5943 out.prob.x_u.clone_from(x_u);
5944 }
5945 if let Some(g_l) = &v.g_l {
5946 check("g_l", g_l.len(), self.prob.m)?;
5947 out.prob.g_l.clone_from(g_l);
5948 }
5949 if let Some(g_u) = &v.g_u {
5950 check("g_u", g_u.len(), self.prob.m)?;
5951 out.prob.g_u.clone_from(g_u);
5952 }
5953 Ok(out)
5954 }
5955
5956 /// Build one [`NlTnlp`] per variation, sharing this instance's
5957 /// structure (see [`Self::variant`]). Returns instances in input
5958 /// order; errors on the first length-mismatched variation.
5959 pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5960 vs.iter().map(|v| self.variant(v)).collect()
5961 }
5962}
5963
5964/// Per-instance overrides for building a family of related NLP
5965/// instances from one parsed `.nl` model (pounce#126): same structure
5966/// and tapes, different starting point and/or bounds — parametric
5967/// sweeps, multi-start, or branch-and-bound node relaxations where
5968/// each node only tightens variable bounds. `None` keeps the base
5969/// model's value.
5970#[derive(Debug, Clone, Default)]
5971pub struct NlVariation {
5972 pub x0: Option<Vec<Number>>,
5973 pub x_l: Option<Vec<Number>>,
5974 pub x_u: Option<Vec<Number>>,
5975 pub g_l: Option<Vec<Number>>,
5976 pub g_u: Option<Vec<Number>>,
5977}
5978
5979impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5980 /// Per-`.nl`-row constraint expression tape, with the linear
5981 /// part folded in. Returns `None` for constraints that contribute
5982 /// neither a nonlinear expression nor any linear coefficients
5983 /// (so FBBT skips them — there's nothing to tighten).
5984 fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5985 if i >= self.prob.con_nonlinear.len() {
5986 return None;
5987 }
5988 let nonlinear = self.prob.con_expr(i);
5989 let linear = self
5990 .prob
5991 .con_linear
5992 .get(i)
5993 .map(|v| v.as_slice())
5994 .unwrap_or(&[]);
5995 // FBBT needs the tree, so a recognized body is rebuilt here. It is
5996 // rebuilt once per row per presolve pass and dropped again, so the
5997 // DAG never all exists at once — which is the property the phase is
5998 // actually about. Translating the stored coefficients instead would
5999 // propagate bounds through a different association and change the
6000 // tightening; that is not a trade this phase makes.
6001 crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
6002 }
6003
6004 /// Variable name from the sibling `.col` file, if one was loaded.
6005 /// Index is original `.nl` column order.
6006 fn variable_name(&self, i: usize) -> Option<&str> {
6007 self.prob.var_names.get(i).map(String::as_str)
6008 }
6009
6010 /// Constraint name from the sibling `.row` file, if one was loaded.
6011 /// Index is original `.nl` row order.
6012 fn constraint_name(&self, i: usize) -> Option<&str> {
6013 self.prob.con_names.get(i).map(String::as_str)
6014 }
6015}
6016
6017/// Per-worker scratch for the parallel Hessian walk. Four buffers of
6018/// `max_tape_n` — the largest tape's node count, tens of entries on the models
6019/// this path is for — so a worker's copy costs about a kilobyte.
6020#[cfg(not(target_arch = "wasm32"))]
6021struct HessScratch {
6022 vals: Vec<f64>,
6023 dot: Vec<f64>,
6024 adj: Vec<f64>,
6025 adj_dot: Vec<f64>,
6026}
6027
6028/// Per-worker scratch for the parallel Jacobian walk: one gradient row of
6029/// length `n` plus the two small tape arenas `gradient_seed_into` fills.
6030#[cfg(not(target_arch = "wasm32"))]
6031#[derive(Debug, Clone)]
6032struct JacScratch {
6033 row_grad: Vec<f64>,
6034 vals: Vec<f64>,
6035 adj: Vec<f64>,
6036}
6037
6038#[cfg(not(target_arch = "wasm32"))]
6039impl JacScratch {
6040 fn new(n: usize, max_tape_n: usize) -> Self {
6041 Self {
6042 row_grad: vec![0.0; n],
6043 vals: vec![0.0; max_tape_n],
6044 adj: vec![0.0; max_tape_n],
6045 }
6046 }
6047}
6048
6049#[cfg(not(target_arch = "wasm32"))]
6050impl HessScratch {
6051 fn new(n: usize) -> Self {
6052 Self {
6053 vals: vec![0.0; n],
6054 dot: vec![0.0; n],
6055 adj: vec![0.0; n],
6056 adj_dot: vec![0.0; n],
6057 }
6058 }
6059}
6060
6061impl NlTnlp {
6062 /// Smallest (row, tape) × color work list that earns a parallel walk.
6063 /// Below it the rayon dispatch costs more than the sweeps it splits, and
6064 /// the serial tape-major walk is also cheaper per unit work because it
6065 /// forwards each tape once rather than once per color.
6066 ///
6067 /// Measured on a banded synthetic (`sin(x_i·x_{i+1}) + x_{i+1}²·x_{i+2}`
6068 /// per row, the model in `parallel_hessian_matches_serial.rs`), serial ->
6069 /// parallel with the gate forced open:
6070 ///
6071 /// | pairs | serial | parallel | ratio |
6072 /// |---|---|---|---|
6073 /// | 400 | 0.028 ms | 0.036 ms | 0.79x |
6074 /// | 1 200 | 0.073 ms | 0.066 ms | 1.11x |
6075 /// | 4 000 | 0.179 ms | 0.112 ms | 1.60x |
6076 /// | 12 000 | 0.344 ms | 0.203 ms | 1.69x |
6077 ///
6078 /// The crossover sits between 400 and 1 200, hence this value.
6079 ///
6080 /// **What that table does not say is how big the win gets.** That model
6081 /// has three Hessian colors, so it has three tasks to hand out however
6082 /// many cores are free, and 1.7x is near its ceiling. The parallelism
6083 /// available here *is* the color count: an AC-SCOPF model with 28 colors
6084 /// runs the same walk 4.9x faster (108.3 ms -> 22.2 ms at 54 859
6085 /// variables, 421.0 ms -> 89.0 ms at 209 755). Gating on pair count alone
6086 /// is therefore conservative in the safe direction — at equal pairs, more
6087 /// colors can only help.
6088 const HESS_PAR_MIN_PAIRS: usize = 1024;
6089
6090 /// Whether the flat Hessian walk runs color-major and in parallel.
6091 ///
6092 /// `POUNCE_NL_PARALLEL_EVAL` overrides the size gate: `0` forces the
6093 /// serial walk, `1` forces the parallel one whatever the size. The two
6094 /// produce bit-identical values, so this is a lever for measurement and
6095 /// for pinning a suspected parallelism bug, not a correctness switch —
6096 /// and `1` is how the threshold below was measured, since the gate would
6097 /// otherwise hide the small end of the curve.
6098 #[cfg(not(target_arch = "wasm32"))]
6099 fn hess_flat_parallel(&self) -> bool {
6100 // More than one color is a hard requirement, not a heuristic: with one
6101 // color there is one task and the inversion is pure overhead.
6102 let multi_color = self.compressed.len() > 1;
6103 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6104 Ok("0") | Ok("no") | Ok("false") => false,
6105 Ok("1") | Ok("yes") | Ok("true") => multi_color,
6106 _ => multi_color && self.hess_color_index.con_row.len() >= Self::HESS_PAR_MIN_PAIRS,
6107 }
6108 }
6109
6110 /// Smallest Jacobian nonzero count that earns a parallel walk. Same shape
6111 /// of trade as [`Self::HESS_PAR_MIN_PAIRS`], but a cheaper one to cross:
6112 /// the row walk is *not* reordered or duplicated, so the only cost is the
6113 /// dispatch and one length-`n` scratch row per worker.
6114 #[cfg(not(target_arch = "wasm32"))]
6115 const JAC_PAR_MIN_NNZ: usize = 4096;
6116
6117 /// Smallest row count that earns a parallel constraint evaluation. This is
6118 /// the cheapest of the three walks per row — one forward sweep, no
6119 /// gradient, no directional derivative — so the dispatch is a larger share
6120 /// of it and the threshold is the highest of the three.
6121 ///
6122 /// Measured on the banded synthetic, serial -> parallel with the gate
6123 /// forced open: 500 rows 0.34x, 2 000 0.84x, 5 000 1.07x, 8 000 1.53x,
6124 /// 20 000 3.19x, 60 000 4.97x, 200 000 5.78x. The crossover is near 5 000,
6125 /// so the gate sits just under it. Unlike the Hessian, this walk has no
6126 /// color ceiling — every row is its own task — which is why it keeps
6127 /// climbing to ~6x where the Hessian's 3-color model stalls at 1.7x.
6128 #[cfg(not(target_arch = "wasm32"))]
6129 const EVAL_G_PAR_MIN_ROWS: usize = 4096;
6130
6131 /// Whether `eval_g` runs in parallel over rows. Shares
6132 /// `POUNCE_NL_PARALLEL_EVAL` with the other two walks.
6133 #[cfg(not(target_arch = "wasm32"))]
6134 fn eval_g_parallel(&self) -> bool {
6135 // The shared-CSE arm returns earlier; this gate only ever sees the
6136 // flat path.
6137 let big = self.prob.m >= Self::EVAL_G_PAR_MIN_ROWS;
6138 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6139 Ok("0") | Ok("no") | Ok("false") => false,
6140 Ok("1") | Ok("yes") | Ok("true") => true,
6141 _ => big,
6142 }
6143 }
6144
6145 /// Whether the flat Jacobian walk runs in parallel over row groups.
6146 /// Shares `POUNCE_NL_PARALLEL_EVAL` with the Hessian.
6147 #[cfg(not(target_arch = "wasm32"))]
6148 fn jac_flat_parallel(&self) -> bool {
6149 let big = self.jac_nnz >= Self::JAC_PAR_MIN_NNZ;
6150 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6151 Ok("0") | Ok("no") | Ok("false") => false,
6152 Ok("1") | Ok("yes") | Ok("true") => true,
6153 _ => big,
6154 }
6155 }
6156
6157 /// The flat Jacobian, walked in parallel over contiguous groups of rows.
6158 ///
6159 /// Unlike the Hessian this needs no inversion: rows are already
6160 /// independent — each zeroes only its own columns of the gradient scratch,
6161 /// accumulates into them, and writes a contiguous run of `values`. So the
6162 /// work is split by *rows*, each group keeping the serial order inside
6163 /// itself, and every row computes exactly what it computed serially. The
6164 /// results are bit-identical for the stronger reason that nothing was
6165 /// reordered at all.
6166 ///
6167 /// Two things are per-worker rather than per-call: the length-`n` gradient
6168 /// row (the reason this is not simply `par_iter`, since `n` can be tens of
6169 /// thousands and allocating one per row would dwarf the work) and the two
6170 /// small tape arenas. They are pooled on `self` and reused across
6171 /// evaluations.
6172 #[cfg(not(target_arch = "wasm32"))]
6173 fn jac_flat_rows_parallel(&mut self, x_arg: Option<&[Number]>, values: &mut [Number]) {
6174 use rayon::prelude::*;
6175 let n = self.prob.n;
6176 let m = self.prob.m;
6177 let groups = rayon::current_num_threads().clamp(1, m.max(1));
6178 let max_tape_n = self.vals_scratch.len();
6179
6180 // Row -> offset of its run in `values`, so a group can be hnaded one
6181 // contiguous slice. Rebuilt only when the shape changes.
6182 if self.jac_row_off.len() != m + 1 {
6183 self.jac_row_off = Vec::with_capacity(m + 1);
6184 let mut acc = 0u32;
6185 for cols in &self.jac_cols {
6186 self.jac_row_off.push(acc);
6187 acc += cols.len() as u32;
6188 }
6189 self.jac_row_off.push(acc);
6190 }
6191 if self.jac_scratch_pool.len() != groups {
6192 self.jac_scratch_pool = (0..groups)
6193 .map(|_| JacScratch::new(n, max_tape_n))
6194 .collect();
6195 }
6196
6197 // Equal-row groups. Rows differ in width, so this is not a perfect
6198 // balance; rayon is given more groups than threads only when that is
6199 // free, and the measurement below is taken with this split.
6200 let per = m.div_ceil(groups.max(1));
6201 let Self {
6202 prob,
6203 con_tapes,
6204 quad,
6205 jac_cols,
6206 jac_row_off,
6207 jac_scratch_pool,
6208 ..
6209 } = self;
6210 let xs = x_arg.unwrap_or(&prob.x0);
6211
6212 // Hand each group its own contiguous run of `values`.
6213 let mut rest: &mut [Number] = values;
6214 let mut slices: Vec<&mut [Number]> = Vec::with_capacity(groups);
6215 for g in 0..groups {
6216 let end_row = ((g + 1) * per).min(m);
6217 let start_row = (g * per).min(m);
6218 let len = (jac_row_off[end_row] - jac_row_off[start_row]) as usize;
6219 let (head, tail) = rest.split_at_mut(len);
6220 slices.push(head);
6221 rest = tail;
6222 }
6223
6224 jac_scratch_pool
6225 .par_iter_mut()
6226 .zip(slices.into_par_iter())
6227 .enumerate()
6228 .for_each(|(g, (sc, out))| {
6229 let (lo, hi) = ((g * per).min(m), ((g + 1) * per).min(m));
6230 let JacScratch {
6231 row_grad,
6232 vals,
6233 adj,
6234 } = sc;
6235 let mut k = 0usize;
6236 for i in lo..hi {
6237 for &j in &jac_cols[i] {
6238 row_grad[j] = 0.0;
6239 }
6240 for t in &con_tapes[i] {
6241 t.gradient_seed_into(xs, 1.0, row_grad, vals, adj);
6242 }
6243 if let Some(f) = quad.row_form(i) {
6244 quad.add_gradient(f, xs, 1.0, row_grad);
6245 }
6246 for &(v, c) in &prob.con_linear[i] {
6247 row_grad[v] += c;
6248 }
6249 for &j in &jac_cols[i] {
6250 out[k] = row_grad[j];
6251 k += 1;
6252 }
6253 }
6254 });
6255 }
6256
6257 /// The flat Hessian's constraint block, walked color-major with one rayon
6258 /// task per color.
6259 ///
6260 /// Each task owns its color's `compressed[c]` outright, so nothing is
6261 /// shared mutably and no reduction is needed. Within a color the pairs are
6262 /// visited in ascending (row, tape) order — the order the serial walk
6263 /// contributes them in — so the sums are identical term by term and the
6264 /// two paths agree bit-for-bit, which
6265 /// `parallel_matches_serial_bit_for_bit` pins.
6266 ///
6267 /// The cost of the inversion is forwarding a tape once per color it
6268 /// touches instead of once for all of them (2.9 colors per tape measured
6269 /// on AC-SCOPF). That is paid back by the parallelism and only above
6270 /// `HESS_PAR_MIN_PAIRS`.
6271 #[cfg(not(target_arch = "wasm32"))]
6272 fn hess_flat_colors_parallel(&mut self, x_arg: Option<&[Number]>, lam: &[Number]) {
6273 use rayon::prelude::*;
6274 let Self {
6275 compressed,
6276 seeds,
6277 con_tapes,
6278 hess_color_index: idx,
6279 vals_scratch,
6280 prob,
6281 ..
6282 } = self;
6283 let x = x_arg.unwrap_or(&prob.x0);
6284 let max_tape_n = vals_scratch.len();
6285 compressed.par_iter_mut().enumerate().for_each_init(
6286 || HessScratch::new(max_tape_n),
6287 |sc, (c, out)| {
6288 let (rows, tapes) = idx.con_pairs(c);
6289 let seed = &seeds[c];
6290 let HessScratch {
6291 vals,
6292 dot,
6293 adj,
6294 adj_dot,
6295 } = sc;
6296 for (&k, &ti) in rows.iter().zip(tapes.iter()) {
6297 let w = lam[k as usize];
6298 if w == 0.0 {
6299 continue;
6300 }
6301 let t = &con_tapes[k as usize][ti as usize];
6302 if t.ops.is_empty() {
6303 continue;
6304 }
6305 t.forward_into(x, vals);
6306 t.hessian_directional(vals, seed, w, out, dot, adj, adj_dot);
6307 }
6308 },
6309 );
6310 }
6311}
6312
6313impl TNLP for NlTnlp {
6314 /// An `.nl` model carries no callback: the CLI and the browser build
6315 /// read the iteration log, not `IterStats` (gh#965).
6316 fn wants_intermediate_callback(&self) -> bool {
6317 false
6318 }
6319
6320 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
6321 Some(NlpInfo {
6322 n: self.prob.n as Index,
6323 m: self.prob.m as Index,
6324 nnz_jac_g: self.jac_nnz as Index,
6325 nnz_h_lag: self.h_irow.len() as Index,
6326 index_style: IndexStyle::C,
6327 })
6328 }
6329
6330 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
6331 b.x_l.copy_from_slice(&self.prob.x_l);
6332 b.x_u.copy_from_slice(&self.prob.x_u);
6333 if !self.prob.g_l.is_empty() {
6334 b.g_l.copy_from_slice(&self.prob.g_l);
6335 b.g_u.copy_from_slice(&self.prob.g_u);
6336 }
6337 true
6338 }
6339
6340 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
6341 sp.x.copy_from_slice(&self.prob.x0);
6342 // The `.nl` `d` segment supplies initial constraint multipliers
6343 // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
6344 // the engine when `warm_start_init_point yes` — by handing them
6345 // back; `OrigIpoptNlp::get_starting_point` then compresses them into
6346 // the algorithm-side y_c / y_d. Without this the warm start silently
6347 // began from zero multipliers, discarding the parsed duals. (Code
6348 // review 2026-06 item M19.)
6349 if sp.init_lambda {
6350 sp.lambda.copy_from_slice(&self.prob.lambda0);
6351 }
6352 // Bound multipliers come from the `ipopt_zL_in` / `ipopt_zU_in`
6353 // variable suffixes, as in Ipopt's `AmplTNLP::get_starting_point`:
6354 // `z_L = sigma * zL_in`, `z_U = -sigma * zU_in` (sigma = -1 for a
6355 // maximize model) — the inverse of the CLI's `ipopt_zL_out` /
6356 // `ipopt_zU_out`, so a `.sol` result fed back in round-trips. A
6357 // suffix the file does not carry leaves that block to the engine's
6358 // defaults, as before.
6359 if sp.init_z {
6360 let sigma = if self.prob.minimize { 1.0 } else { -1.0 };
6361 let sfx = &self.prob.suffixes;
6362 for (name, out, sign) in [
6363 ("ipopt_zL_in", &mut *sp.z_l, sigma),
6364 ("ipopt_zU_in", &mut *sp.z_u, -sigma),
6365 ] {
6366 // A writer may emit an all-integer suffix as integer-kind.
6367 let vals: Option<Vec<Number>> = sfx.var_real.get(name).cloned().or_else(|| {
6368 sfx.var_int
6369 .get(name)
6370 .map(|v| v.iter().map(|&x| x as Number).collect())
6371 });
6372 if let Some(v) = vals.filter(|v| v.len() == out.len()) {
6373 for (o, x) in out.iter_mut().zip(v) {
6374 *o = sign * x;
6375 }
6376 }
6377 }
6378 }
6379 true
6380 }
6381
6382 /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
6383 /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
6384 /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
6385 /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
6386 /// writes into. Before gh#483 nothing implemented this callback for
6387 /// `.nl` input, so a tagged model reached the solver with the option
6388 /// accepted and *no* scaling applied, silently.
6389 ///
6390 /// Returns `false` (engine falls back to no scaling) when the file
6391 /// declares no `scaling_factor` suffix at all — the same "user
6392 /// supplied nothing" answer as the default `TNLP` impl.
6393 ///
6394 /// AMPL suffix vectors default to **0** for components the model did
6395 /// not tag, and 0 is not a usable scale factor. A zero entry is
6396 /// therefore read as "not tagged" and becomes 1.0, which is what
6397 /// "unlisted components are unscaled" means. Per-variable factors
6398 /// are passed straight through: `OrigIpoptNlp` does not model them
6399 /// and refuses the solve with a message rather than dropping them.
6400 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
6401 const NAME: &str = "scaling_factor";
6402 let sfx = &self.prob.suffixes;
6403 let obj = sfx.obj_real.get(NAME);
6404 let var = sfx.var_real.get(NAME);
6405 let con = sfx.con_real.get(NAME);
6406 let computed = self.curvature_scaling.as_ref();
6407 if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
6408 return false;
6409 }
6410 // Curvature-based factors (gh #703) are the *base*; a
6411 // `scaling_factor` suffix the model actually carries overrides
6412 // them component by component, because an explicit factor from the
6413 // modeller beats one inferred from the coefficients. A model with
6414 // no suffixes is the ordinary case and gets the computed vectors
6415 // whole.
6416 if let Some(sc) = computed {
6417 // The length guards are a `copy_from_slice` panic guard, not a
6418 // policy: `curvature_scaling` sizes both vectors from the same
6419 // `NlProblem` this callback is answering for, so a mismatch is a
6420 // bug upstream, not a model the scheme declines. Declining is the
6421 // *worst* available response to it — `use_*_scaling` stays false,
6422 // the engine reads that as "user supplied nothing", and the run
6423 // proceeds unscaled with the option accepted, which is gh #483
6424 // again. Assert it in debug builds so a mismatch is found here,
6425 // where it is one line, instead of as a slow solve later.
6426 debug_assert_eq!(
6427 sc.x.len(),
6428 req.x_scaling.len(),
6429 "curvature x-scaling sized {} for a {}-variable request",
6430 sc.x.len(),
6431 req.x_scaling.len()
6432 );
6433 debug_assert_eq!(
6434 sc.g.len(),
6435 req.g_scaling.len(),
6436 "curvature g-scaling sized {} for a {}-row request",
6437 sc.g.len(),
6438 req.g_scaling.len()
6439 );
6440 if sc.x.len() == req.x_scaling.len() {
6441 req.x_scaling.copy_from_slice(&sc.x);
6442 *req.use_x_scaling = true;
6443 }
6444 if sc.g.len() == req.g_scaling.len() {
6445 req.g_scaling.copy_from_slice(&sc.g);
6446 *req.use_g_scaling = true;
6447 }
6448 }
6449 // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
6450 // are parsed and ignored), so its entry is the objective scale.
6451 *req.obj_scaling = obj
6452 .and_then(|v| v.first().copied())
6453 .filter(|&s| s != 0.0)
6454 .unwrap_or(1.0);
6455 // A zero entry is AMPL's "untagged" default, not a scale factor.
6456 // An untagged component therefore falls back to the base: the
6457 // curvature factor when one was computed, and an explicit 1.0
6458 // otherwise — explicit because the callback's contract is to fill
6459 // the buffer, not to assume the caller pre-filled it with ones.
6460 if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
6461 for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
6462 if s != 0.0 {
6463 *slot = s;
6464 } else if computed.is_none() {
6465 *slot = 1.0;
6466 }
6467 }
6468 *req.use_x_scaling = true;
6469 } else if computed.is_none() {
6470 *req.use_x_scaling = false;
6471 }
6472 if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
6473 for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
6474 if s != 0.0 {
6475 *slot = s;
6476 } else if computed.is_none() {
6477 *slot = 1.0;
6478 }
6479 }
6480 *req.use_g_scaling = true;
6481 } else if computed.is_none() {
6482 *req.use_g_scaling = false;
6483 }
6484 true
6485 }
6486
6487 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
6488 // Reuse the shared forward-value arena (sized to `max_tape_n`) so
6489 // each summand sweep allocates nothing — see `Tape::eval_into`.
6490 let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
6491 let mut nl: Number = 0.0;
6492 for t in obj_tapes {
6493 nl += t.eval_into(x, vals);
6494 }
6495 // A recognized objective has no tapes, so the loop above added
6496 // nothing and the form supplies the whole nonlinear part — including
6497 // the linear and constant terms AMPL folded into that tree, which is
6498 // why they are *not* also in `obj_linear` / `obj_constant`.
6499 if let Some(f) = self.quad.objective_form() {
6500 nl += self.quad.value(f, x);
6501 }
6502 let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
6503 let v = self.prob.obj_constant + nl + lin;
6504 let signed = if self.prob.minimize { v } else { -v };
6505 Some(signed)
6506 }
6507
6508 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
6509 grad.fill(0.0);
6510 // Reuse the forward-value / adjoint scratch arenas (sized to
6511 // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
6512 // nothing — see `Tape::gradient_seed_into` (M18).
6513 for t in &self.obj_tapes {
6514 t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
6515 }
6516 if let Some(f) = self.quad.objective_form() {
6517 self.quad.add_gradient(f, x, 1.0, grad);
6518 }
6519 for (i, c) in &self.prob.obj_linear {
6520 grad[*i] += c;
6521 }
6522 if !self.prob.minimize {
6523 for g in grad.iter_mut() {
6524 *g = -*g;
6525 }
6526 }
6527 true
6528 }
6529
6530 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
6531 // Constraint values are the line search's inner loop: on a
6532 // constraint-heavy model (m >> n) this runs ~10x per iteration over
6533 // every summand tape in the problem. Reuse the shared forward-value
6534 // arena so the sweep allocates nothing — the per-summand `Vec` the
6535 // allocating `Tape::eval` used to build was ~20% of `eval_g` on
6536 // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
6537 // `Tape::eval_into`.
6538 let m = self.prob.m;
6539 let con_linear = &self.prob.con_linear;
6540 let quad = &self.quad;
6541 if let Some(h) = &mut self.con_hybrid {
6542 // Shared CSE bodies once for the whole constraint block, then one
6543 // local sweep per summand (pounce#476).
6544 let ConHybrid {
6545 tape,
6546 row_start,
6547 prelude_vals,
6548 local_vals,
6549 ..
6550 } = h;
6551 tape.forward_prelude(x, prelude_vals);
6552 for i in 0..m {
6553 let mut nl: Number = 0.0;
6554 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6555 tape.forward_summand(s, x, prelude_vals, local_vals);
6556 nl += tape.root_value(s, local_vals);
6557 }
6558 if let Some(f) = quad.row_form(i) {
6559 nl += quad.value(f, x);
6560 }
6561 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6562 g[i] = nl + lin;
6563 }
6564 return true;
6565 }
6566 // Row `i` reads only `x` and writes only `g[i]`, so this one is
6567 // parallel as written — no inversion, no row-offset bookkeeping, and
6568 // the per-row arithmetic is untouched, which is why the values are
6569 // bit-identical. Only the forward-value arena is per-worker.
6570 #[cfg(not(target_arch = "wasm32"))]
6571 if self.eval_g_parallel() {
6572 use rayon::prelude::*;
6573 let max_tape_n = self.vals_scratch.len();
6574 let con_tapes = &self.con_tapes;
6575 g.par_iter_mut().enumerate().for_each_init(
6576 || vec![0.0; max_tape_n],
6577 |vals, (i, gi)| {
6578 let mut nl: Number = 0.0;
6579 for t in &con_tapes[i] {
6580 nl += t.eval_into(x, vals);
6581 }
6582 if let Some(f) = quad.row_form(i) {
6583 nl += quad.value(f, x);
6584 }
6585 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6586 *gi = nl + lin;
6587 },
6588 );
6589 return true;
6590 }
6591 let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
6592 for i in 0..m {
6593 let mut nl: Number = 0.0;
6594 for t in &con_tapes[i] {
6595 nl += t.eval_into(x, vals);
6596 }
6597 // A quadratic row's summand range is empty above, so this is its
6598 // whole nonlinear part: one matvec against a constant matrix in
6599 // place of a walk over every monomial's tape.
6600 if let Some(f) = quad.row_form(i) {
6601 nl += quad.value(f, x);
6602 }
6603 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6604 g[i] = nl + lin;
6605 }
6606 true
6607 }
6608
6609 fn eval_jac_g(
6610 &mut self,
6611 x: Option<&[Number]>,
6612 _new_x: bool,
6613 mode: SparsityRequest<'_>,
6614 ) -> bool {
6615 match mode {
6616 SparsityRequest::Structure { irow, jcol } => {
6617 let mut k = 0;
6618 for i in 0..self.prob.m {
6619 for &j in &self.jac_cols[i] {
6620 irow[k] = i as Index;
6621 jcol[k] = j as Index;
6622 k += 1;
6623 }
6624 }
6625 true
6626 }
6627 SparsityRequest::Values { values } => {
6628 let n = self.prob.n;
6629 if self.scratch_row_grad.len() < n {
6630 self.scratch_row_grad.resize(n, 0.0);
6631 }
6632 // Rows are independent and write disjoint runs of `values`, so
6633 // the parallel walk is the same work in the same order, split
6634 // by row group. Only the flat path: the shared-CSE arm below
6635 // forwards one prelude for the whole block, which is exactly
6636 // the sharing a row split would have to undo.
6637 #[cfg(not(target_arch = "wasm32"))]
6638 if self.con_hybrid.as_ref().filter(|h| h.use_for_jac).is_none()
6639 && self.jac_flat_parallel()
6640 {
6641 self.jac_flat_rows_parallel(x, values);
6642 return true;
6643 }
6644 let Self {
6645 prob,
6646 con_tapes,
6647 con_hybrid,
6648 quad,
6649 jac_cols,
6650 scratch_row_grad,
6651 vals_scratch,
6652 adj_scratch,
6653 ..
6654 } = self;
6655 let xs = x.unwrap_or(&prob.x0);
6656 let mut k = 0;
6657 // Shared-CSE path: the forward sweep over the CSE bodies runs
6658 // once for the whole constraint block instead of once per
6659 // referencing summand. The reverse sweep cannot be shared —
6660 // each row needs its own gradient — so a summand still walks
6661 // its own `prelude_reach` backwards.
6662 if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
6663 let ConHybrid {
6664 tape,
6665 row_start,
6666 prelude_vals,
6667 local_vals,
6668 local_adj,
6669 prelude_adj,
6670 ..
6671 } = h;
6672 tape.forward_prelude(xs, prelude_vals);
6673 for i in 0..prob.m {
6674 for &j in &jac_cols[i] {
6675 scratch_row_grad[j] = 0.0;
6676 }
6677 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6678 tape.forward_summand(s, xs, prelude_vals, local_vals);
6679 tape.gradient_summand(
6680 s,
6681 prelude_vals,
6682 local_vals,
6683 1.0,
6684 scratch_row_grad,
6685 local_adj,
6686 prelude_adj,
6687 );
6688 }
6689 if let Some(f) = quad.row_form(i) {
6690 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6691 }
6692 for &(v, c) in &prob.con_linear[i] {
6693 scratch_row_grad[v] += c;
6694 }
6695 for &j in &jac_cols[i] {
6696 values[k] = scratch_row_grad[j];
6697 k += 1;
6698 }
6699 }
6700 return true;
6701 }
6702 for i in 0..prob.m {
6703 for &j in &jac_cols[i] {
6704 scratch_row_grad[j] = 0.0;
6705 }
6706 for t in &con_tapes[i] {
6707 // Allocation-free reverse-AD per summand tape (M18):
6708 // reuse the shared forward/adjoint scratch arenas.
6709 t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
6710 }
6711 // `Hx + a` for a quadratic row: one matvec over the
6712 // row's support, in place of a reverse sweep per
6713 // monomial. `jac_cols[i]` already covers the form's
6714 // gradient support, so the scatter lands inside the
6715 // window zeroed above.
6716 if let Some(f) = quad.row_form(i) {
6717 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6718 }
6719 for &(v, c) in &prob.con_linear[i] {
6720 scratch_row_grad[v] += c;
6721 }
6722 for &j in &jac_cols[i] {
6723 values[k] = scratch_row_grad[j];
6724 k += 1;
6725 }
6726 }
6727 true
6728 }
6729 }
6730 }
6731
6732 fn eval_h(
6733 &mut self,
6734 x: Option<&[Number]>,
6735 _new_x: bool,
6736 obj_factor: Number,
6737 lambda: Option<&[Number]>,
6738 _new_lambda: bool,
6739 mode: SparsityRequest<'_>,
6740 ) -> bool {
6741 match mode {
6742 SparsityRequest::Structure { irow, jcol } => {
6743 irow.copy_from_slice(&self.h_irow);
6744 jcol.copy_from_slice(&self.h_jcol);
6745 true
6746 }
6747 SparsityRequest::Values { values } => {
6748 // Kept alongside the resolved slice: the parallel walk below
6749 // resolves it again *after* destructuring `self`, so `x` and
6750 // the buffers it writes borrow different fields and neither
6751 // has to be copied per evaluation.
6752 let x_arg = x;
6753 let x = x_arg.unwrap_or(&self.prob.x0);
6754 values.fill(0.0);
6755
6756 let obj_seed = if self.prob.minimize {
6757 obj_factor
6758 } else {
6759 -obj_factor
6760 };
6761 // Coloring path. For each (tape, weight) we do
6762 // one forward pass into `vals_scratch`, then one
6763 // forward-tangent+reverse-over-tangent per color
6764 // touched by that tape. Each pass accumulates a
6765 // weighted contribution of (H_tape · seed_c) into
6766 // `compressed[c]`. After all tapes done, we
6767 // decode each color's compressed vector into the
6768 // sparse `values` array.
6769 for buf in &mut self.compressed {
6770 buf.fill(0.0);
6771 }
6772
6773 // The constant blocks first: no forward sweep, no
6774 // directional product, no decode — the multipliers are the
6775 // only thing that changed since the model was read, so this
6776 // is one `values[slot] += w · h` pass per live form. Skipped
6777 // wholesale on a model with nothing recognized, so such a
6778 // model does not pay an `O(m)` scan for a structure that is
6779 // empty.
6780 if !self.quad.is_empty() {
6781 if obj_seed != 0.0 {
6782 if let Some(f) = self.quad.objective_form() {
6783 self.quad.accumulate_hessian(f, obj_seed, values);
6784 }
6785 }
6786 if let Some(lam) = lambda {
6787 for i in 0..self.prob.m {
6788 let w = lam[i];
6789 if w == 0.0 {
6790 continue;
6791 }
6792 if let Some(f) = self.quad.row_form(i) {
6793 self.quad.accumulate_hessian(f, w, values);
6794 }
6795 }
6796 }
6797 }
6798
6799 if obj_seed != 0.0 {
6800 for (ti, t) in self.obj_tapes.iter().enumerate() {
6801 if t.ops.is_empty() {
6802 continue;
6803 }
6804 t.forward_into(x, &mut self.vals_scratch);
6805 for &c in &self.obj_tape_colors[ti] {
6806 t.hessian_directional(
6807 &self.vals_scratch,
6808 &self.seeds[c as usize],
6809 obj_seed,
6810 &mut self.compressed[c as usize],
6811 &mut self.dot_scratch,
6812 &mut self.adj_scratch,
6813 &mut self.adj_dot_scratch,
6814 );
6815 }
6816 }
6817 }
6818
6819 // Computed before the match: its scrutinee takes `self` mutably,
6820 // so a guard cannot also read `self`. Absent on wasm32 along
6821 // with the arm it guards — the arm still has to *typecheck*
6822 // there, dead or not, and the method it calls does not exist.
6823 #[cfg(not(target_arch = "wasm32"))]
6824 let hess_flat_parallel = self.hess_flat_parallel();
6825 match (lambda, self.con_hybrid.as_mut()) {
6826 // Shared-CSE path (issue #557). Per color the prelude's
6827 // second-order work runs ONCE for the whole constraint
6828 // block: one forward tangent, then — because
6829 // reverse-over-tangent is linear in its adjoint seeds —
6830 // one unit-weight reverse sweep over the λ-weighted
6831 // adjoints accumulated by every summand of that color.
6832 // The flat path below repeats both sweeps over the
6833 // inlined CSE body once per referencing summand.
6834 (Some(lam), Some(h)) if h.use_for_hess => {
6835 let ConHybrid {
6836 tape,
6837 prelude_vals,
6838 local_vals_all,
6839 local_off,
6840 summand_row,
6841 hess_color_summands,
6842 hess_color_reach,
6843 hess_color_reach_off,
6844 prelude_dot,
6845 hess_prelude_adj,
6846 prelude_adj_dot,
6847 local_dot,
6848 local_adj,
6849 local_adj_dot,
6850 ..
6851 } = h;
6852 // Forward once (values are color-independent):
6853 // prelude for the block, then each summand of a row
6854 // with a live multiplier into its packed slice.
6855 tape.forward_prelude(x, prelude_vals);
6856 for (si, s) in tape.summands.iter().enumerate() {
6857 if lam[summand_row[si] as usize] == 0.0 {
6858 continue;
6859 }
6860 tape.forward_summand(
6861 s,
6862 x,
6863 prelude_vals,
6864 &mut local_vals_all[local_off[si]..local_off[si + 1]],
6865 );
6866 }
6867 for (c, list) in hess_color_summands.iter().enumerate() {
6868 if !list
6869 .iter()
6870 .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
6871 {
6872 continue;
6873 }
6874 let seed = &self.seeds[c];
6875 let out = &mut self.compressed[c];
6876 let creach = &hess_color_reach
6877 [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
6878 tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
6879 for &si in list {
6880 let si = si as usize;
6881 let w = lam[summand_row[si] as usize];
6882 if w == 0.0 {
6883 continue;
6884 }
6885 tape.hessian_summand_directional(
6886 &tape.summands[si],
6887 &local_vals_all[local_off[si]..local_off[si + 1]],
6888 prelude_dot,
6889 seed,
6890 w,
6891 out,
6892 local_dot,
6893 local_adj,
6894 local_adj_dot,
6895 hess_prelude_adj,
6896 prelude_adj_dot,
6897 );
6898 }
6899 tape.prelude_reverse_directional(
6900 prelude_vals,
6901 prelude_dot,
6902 creach,
6903 out,
6904 hess_prelude_adj,
6905 prelude_adj_dot,
6906 );
6907 }
6908 }
6909 // Color-major and parallel when the work is big enough
6910 // to pay for the dispatch; bit-identical to the walk
6911 // below, which stays the path every small model takes.
6912 #[cfg(not(target_arch = "wasm32"))]
6913 (Some(lam), _) if hess_flat_parallel => {
6914 self.hess_flat_colors_parallel(x_arg, lam);
6915 }
6916 (Some(lam), _) => {
6917 for k in 0..self.prob.m {
6918 let w = lam[k];
6919 if w == 0.0 {
6920 continue;
6921 }
6922 for (ti, t) in self.con_tapes[k].iter().enumerate() {
6923 if t.ops.is_empty() {
6924 continue;
6925 }
6926 t.forward_into(x, &mut self.vals_scratch);
6927 for &c in &self.con_tape_colors[k][ti] {
6928 t.hessian_directional(
6929 &self.vals_scratch,
6930 &self.seeds[c as usize],
6931 w,
6932 &mut self.compressed[c as usize],
6933 &mut self.dot_scratch,
6934 &mut self.adj_scratch,
6935 &mut self.adj_dot_scratch,
6936 );
6937 }
6938 }
6939 }
6940 }
6941 (None, _) => {}
6942 }
6943
6944 // Decode each color's compressed Hessian-vector
6945 // result into the lower-triangle `values` array.
6946 for (c, table) in self.decoding.iter().enumerate() {
6947 let comp = &self.compressed[c];
6948 for w in table {
6949 values[w.hess_idx as usize] += comp[w.row as usize];
6950 }
6951 }
6952 true
6953 }
6954 }
6955 }
6956
6957 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
6958 self.final_x = Some(sol.x.to_vec());
6959 self.final_obj = sol.obj_value;
6960 self.final_lambda = Some(sol.lambda.to_vec());
6961 self.final_z_l = Some(sol.z_l.to_vec());
6962 self.final_z_u = Some(sol.z_u.to_vec());
6963 }
6964
6965 /// Publish the `.col` / `.row` names (captured at load time) under the
6966 /// conventional `idx_names` metadata key, in original `.nl` order. The
6967 /// adapter permutes these into split space (see
6968 /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
6969 /// near-singular Jacobian row as the `mass_balance` equation rather
6970 /// than "row 3" — the model-vs-index gap Lee et al. (2024,
6971 /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
6972 /// model debugging. Declines (returns false) when the model shipped no
6973 /// name files so callers fall back to index labels.
6974 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
6975 let mut any = false;
6976 if !self.prob.var_names.is_empty() {
6977 var.strings
6978 .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
6979 any = true;
6980 }
6981 if !self.prob.con_names.is_empty() {
6982 con.strings
6983 .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
6984 any = true;
6985 }
6986 any
6987 }
6988
6989 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
6990 // A row is linear iff its nonlinear-part expression is the
6991 // identity zero — either left over from initial allocation ("no
6992 // `C<idx>` segment touched this row") or installed by the
6993 // constant-row-body fold in `parse_nl_text`, which shifts a
6994 // variable-free `C<idx>` body into the row bounds precisely so
6995 // that this test is a genuine linearity test and not just an
6996 // identity check (`gh #492`).
6997 for (i, t) in types.iter_mut().enumerate() {
6998 *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
6999 Linearity::Linear
7000 } else {
7001 Linearity::NonLinear
7002 };
7003 }
7004 true
7005 }
7006
7007 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7008 // Global linearity, per the upstream TNLP contract: a variable is
7009 // NonLinear iff it appears in the nonlinear part of the objective
7010 // or of any constraint; otherwise Linear. The parsed `.nl` splits
7011 // every row into a linear part (J/G coefficient list) and a
7012 // nonlinear expression, so the set of nonlinear variables is
7013 // exactly the structural union of `collect_vars` over
7014 // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
7015 // only by a linear part — or not referenced at all — is Linear.
7016 //
7017 let nonlinear = self.nonlinear_var_set();
7018 for (i, t) in types.iter_mut().enumerate() {
7019 *t = if nonlinear.contains(&i) {
7020 Linearity::NonLinear
7021 } else {
7022 Linearity::Linear
7023 };
7024 }
7025 true
7026 }
7027
7028 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7029 // Objective-scoped variant of `get_variables_linearity`: only
7030 // `obj_nonlinear` contributes. This is what engages the presolve
7031 // auxiliary-elimination safeguard (pounce-presolve H11): a variable
7032 // that is nonlinear in the objective but happens to have a zero
7033 // gradient at the single probe point (e.g. `f = (x - x0)^2`
7034 // warm-started at `x0`) is kept in the objective support instead of
7035 // being mis-classified objective-free and eliminated. A variable
7036 // that is nonlinear only in *constraints* stays `Linear` here, so
7037 // the guard does not block legitimate eliminations of
7038 // objective-free equality blocks (the gas-network case).
7039 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
7040 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
7041 for (i, t) in types.iter_mut().enumerate() {
7042 *t = if nonlinear.contains(&i) {
7043 Linearity::NonLinear
7044 } else {
7045 Linearity::Linear
7046 };
7047 }
7048 true
7049 }
7050
7051 fn get_number_of_nonlinear_variables(&mut self) -> Index {
7052 self.nonlinear_variables().len() as Index
7053 }
7054
7055 fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
7056 let list = self.nonlinear_variables();
7057 if pos_nonlin_vars.len() < list.len() {
7058 return false;
7059 }
7060 pos_nonlin_vars[..list.len()].copy_from_slice(&list);
7061 true
7062 }
7063
7064 fn derivative_proofs(&mut self) -> DerivativeProofs {
7065 // Degree is the whole argument (gh #588, Q6). A body proved
7066 // degree ≤ 1 has a constant gradient and no second derivative; a
7067 // body proved degree 2 has a nonzero second derivative, hence a
7068 // gradient that moves. A body the recognizer refuses is
7069 // `Unknown` — and `Unknown` must stay `Unknown`, because the
7070 // refusal is structural, not a finding of nonlinearity.
7071 fn proof(affine: Option<bool>) -> DerivativeProof {
7072 match affine {
7073 Some(true) => DerivativeProof::Constant,
7074 Some(false) => DerivativeProof::Varying,
7075 None => DerivativeProof::Unknown,
7076 }
7077 }
7078 let obj_affine = self.prob.obj_nonlinear.provably_affine();
7079 let jac: Vec<DerivativeProof> = self
7080 .prob
7081 .con_nonlinear
7082 .iter()
7083 .map(|b| proof(b.provably_affine()))
7084 .collect();
7085
7086 // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
7087 // makes `∇²L` a non-constant function of `λ` — this is the QCQP
7088 // case, and it is exactly the assertion Ipopt would honour and
7089 // get wrong (§4 Lever 2). Otherwise every row must be *proved*
7090 // affine and the objective *proved* degree ≤ 2, at which point
7091 // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
7092 // reuse on `σ` because the restoration phase passes a different
7093 // one.
7094 let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
7095 DerivativeProof::Varying
7096 } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
7097 DerivativeProof::Constant
7098 } else {
7099 DerivativeProof::Unknown
7100 };
7101
7102 DerivativeProofs {
7103 grad_f: proof(obj_affine),
7104 hessian,
7105 jac,
7106 }
7107 }
7108}
7109
7110/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
7111pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
7112 let prob = read_nl_file(path)?;
7113 Ok(Rc::new(RefCell::new(NlTnlp::try_new(prob)?)))
7114}
7115
7116#[cfg(test)]
7117mod tests {
7118 /// `.nl` text for `min sum_i exp(x_i) + x_i^2` whose sum is a left-deep
7119 /// chain of `2n - 1` binary `o0`, wrapped in `wraps` pairs of unary `o16`
7120 /// (negation, an even count so the value is unchanged). The `o0` chain is
7121 /// read as one n-ary `Sum`; the negations are what stays genuinely deep.
7122 fn deep_sum_nl(n: usize, wraps: usize) -> String {
7123 let mut l = vec![
7124 "g3 1 1 0".to_string(),
7125 format!(" {n} 1 1 0 1"),
7126 " 0 1".into(),
7127 " 0 0".into(),
7128 format!(" 0 {n} 0"),
7129 " 0 0 0 1 0".into(),
7130 " 0 0 0 0 0".into(),
7131 format!(" {n} {n}"),
7132 " 0 0".into(),
7133 " 0 0 0 0 0".into(),
7134 "C0".into(),
7135 "n0".into(),
7136 "O0 0".into(),
7137 ];
7138 l.extend(std::iter::repeat_n("o16".to_string(), 2 * wraps));
7139 l.extend(std::iter::repeat_n("o0".to_string(), 2 * n - 1));
7140 for i in 0..n {
7141 l.extend([
7142 "o44".into(),
7143 format!("v{i}"),
7144 "o2".into(),
7145 format!("v{i}"),
7146 format!("v{i}"),
7147 ]);
7148 }
7149 l.extend(["r".into(), "4 0.5".into(), "b".into()]);
7150 l.extend((0..n).map(|_| "0 -1.0 1.0".to_string()));
7151 l.push(format!("k{}", n - 1));
7152 l.extend((0..n - 1).map(|i| (i + 1).to_string()));
7153 l.push(format!("J0 {n}"));
7154 l.extend((0..n).map(|i| format!("{i} 1.0")));
7155 l.push(format!("G0 {n}"));
7156 l.extend((0..n).map(|i| format!("{i} 0.0")));
7157 l.join("\n") + "\n"
7158 }
7159
7160 /// `ralph1` written as AMPL would: `min 2x - y, x >= 0, y >= 0,
7161 /// (y - x) complements y`. The complementarity row is `5 1 2` — flag 1
7162 /// (y has a finite lower bound), variable 2 (1-based).
7163 fn ralph1_nl(row_line: &str, y_bound: &str) -> String {
7164 [
7165 "g3 1 1 0",
7166 " 2 1 1 0 0", // vars, constraints, objectives, ranges, eqns
7167 " 0 0 1 0", // nonlinear cons, objs; ccons: linear, nonlinear
7168 " 0 0", // network constraints
7169 " 0 0 0", // nonlinear vars in constraints, objectives, both
7170 " 0 0 0 1", // linear network vars; functions; arith, flags
7171 " 0 0 0 0 0", // discrete variables
7172 " 2 2", // Jacobian, gradient nonzeros
7173 " 0 0", // max name lengths
7174 " 0 0 0 0 0", // common expressions
7175 "C0",
7176 "n0",
7177 "O0 0",
7178 "n0",
7179 "r",
7180 row_line,
7181 "b",
7182 "2 0",
7183 y_bound,
7184 "k1",
7185 "1",
7186 "J0 2",
7187 "0 -1",
7188 "1 1",
7189 "G0 2",
7190 "0 2",
7191 "1 -1",
7192 ]
7193 .join("\n")
7194 + "\n"
7195 }
7196
7197 #[test]
7198 fn complementarity_rows_are_read_not_refused() {
7199 let p = parse_nl_string(ralph1_nl("5 1 2", "2 0"), false).unwrap();
7200 assert_eq!(
7201 p.complementarity,
7202 vec![NlComplementarity {
7203 row: 0,
7204 var: 1,
7205 flags: 1
7206 }]
7207 );
7208 // Flag 1 (bounded below): the body must be >= 0.
7209 assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7210 assert_eq!((p.x_l[1], p.x_u[1]), (0.0, 1e19));
7211
7212 // Flag 2 (bounded above, y <= 0): the body must be <= 0.
7213 let p = parse_nl_string(ralph1_nl("5 2 2", "1 0"), false).unwrap();
7214 assert_eq!(p.complementarity[0].flags, 2);
7215 assert_eq!((p.g_l[0], p.g_u[0]), (-1e19, 0.0));
7216
7217 // Flag 0 (free variable): the body is an equation, and no pair.
7218 let p = parse_nl_string(ralph1_nl("5 0 2", "3"), false).unwrap();
7219 assert!(p.complementarity.is_empty());
7220 assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 0.0));
7221
7222 // Flag 3 (mixed) is recorded; the lowering is what refuses it.
7223 let p = parse_nl_string(ralph1_nl("5 3 2", "0 0 1"), false).unwrap();
7224 assert_eq!(p.complementarity[0].flags, 3);
7225
7226 // Malformed lines are errors, not silently dropped pairs.
7227 assert!(parse_nl_string(ralph1_nl("5 1", "2 0"), false).is_err());
7228 assert!(parse_nl_string(ralph1_nl("5 1 0", "2 0"), false).is_err());
7229 assert!(parse_nl_string(ralph1_nl("5 7 2", "2 0"), false).is_err());
7230 }
7231
7232 #[test]
7233 fn a_model_without_complementarity_reads_exactly_as_before() {
7234 // The same model with an ordinary `r` line: no pairs, the row's
7235 // bounds as written.
7236 let p = parse_nl_string(ralph1_nl("2 0", "2 0"), false).unwrap();
7237 assert!(p.complementarity.is_empty());
7238 assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7239 }
7240
7241 /// Run `f` on a thread with the stack the CLI and `pounce-py` provide,
7242 /// declared to the reader the way they declare it.
7243 fn on_big_stack(f: impl FnOnce() + Send + 'static) {
7244 std::thread::Builder::new()
7245 .stack_size(1 << 30)
7246 .spawn(move || {
7247 set_parse_stack_budget(Some(1 << 30));
7248 f();
7249 set_parse_stack_budget(None);
7250 })
7251 .unwrap()
7252 .join()
7253 .unwrap();
7254 }
7255
7256 /// A model whose objective is the last of `len` defined variables, each
7257 /// referencing the previous: `V_0 = x0^2`, `V_k = V_{k-1} + x0`, so the
7258 /// objective is `x0^2 + (len - 1) x0`. Every `V` segment is one level
7259 /// deep to the parser; the chain is `len` deep to anything that walks
7260 /// through it.
7261 fn v_chain_nl(len: usize) -> String {
7262 let mut l = vec![
7263 "g3 1 1 0".to_string(),
7264 " 1 1 1 0 1".into(),
7265 " 0 1".into(),
7266 " 0 0".into(),
7267 " 0 1 0".into(),
7268 " 0 0 0 1 0".into(),
7269 format!(" 0 0 {len} 0 0"),
7270 " 1 1".into(),
7271 " 0 0".into(),
7272 " 0 0 0 0 0".into(),
7273 ];
7274 l.extend(["V1 0 0".into(), "o5".into(), "v0".into(), "n2".into()]);
7275 for k in 1..len {
7276 l.extend([
7277 format!("V{} 0 0", k + 1),
7278 "o0".into(),
7279 format!("v{k}"),
7280 "v0".into(),
7281 ]);
7282 }
7283 l.extend(["C0".into(), "n0".into()]);
7284 l.extend(["O0 0".into(), format!("v{len}")]);
7285 l.extend(["r".into(), "4 0.5".into(), "b".into(), "0 -1.0 1.0".into()]);
7286 l.extend(["k0".into(), "J0 1".into(), "0 1.0".into()]);
7287 l.extend(["G0 1".into(), "0 0.0".into()]);
7288 l.join("\n") + "\n"
7289 }
7290
7291 /// gh#986 review item 9: a chain of defined variables is counted by the
7292 /// depth guard. Each `V` segment is shallow to the parser, so the first
7293 /// pass let a chain of any length through, and every pass that walks
7294 /// through a `Cse` (`collect_vars`, the tape builder, drop glue) then
7295 /// recursed once per link -- an abort on an ordinary stack. On a thread
7296 /// that declared no stack (the default budget) a chain far past the guard
7297 /// is now a clean `Err`.
7298 #[test]
7299 fn gh986_a_long_defined_variable_chain_is_refused_not_overflowed() {
7300 let limit = parse_depth_limit() as usize;
7301 let err = parse_nl_text(&v_chain_nl(limit + 50))
7302 .err()
7303 .expect("a V chain past the guard must be an Err");
7304 assert!(err.contains("defined") && err.contains("levels"), "{err}");
7305 // A short chain on the same thread is fine, and evaluates.
7306 let prob = parse_nl_text(&v_chain_nl(limit / 2)).expect("inside the guard");
7307 let x = [0.5];
7308 let want = 0.25 + (limit / 2 - 1) as f64 * 0.5;
7309 let got = eval_expr(&prob.obj_expr(), &x);
7310 assert!((got - want).abs() < 1e-9, "{got} vs {want}");
7311 }
7312
7313 /// gh#986 review item 9: the depth of a `V` body is memoized, so a long
7314 /// chain inside the guard costs time linear in the chain (the first
7315 /// pass's `expr_tree_depth` re-walked the whole chain on every segment).
7316 #[test]
7317 fn gh986_a_long_defined_variable_chain_inside_the_guard_parses() {
7318 on_big_stack(|| {
7319 // Two levels per link (the `o0` and the `Cse` reference), just
7320 // inside this thread's limit.
7321 let len = (parse_depth_limit() as usize - 100) / 2;
7322 let t = std::time::Instant::now();
7323 let prob = parse_nl_text(&v_chain_nl(len)).expect("inside the guard on a big stack");
7324 let got = eval_expr(&prob.obj_expr(), &[0.5]);
7325 let want = 0.25 + (len - 1) as f64 * 0.5;
7326 assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
7327 // Linear. With only `expr_tree_depth` memoized, the parse-time
7328 // quadratic passes still re-walked the chain per segment and this
7329 // ran past 60 s (measured, test profile); seeded, ~50 ms.
7330 let took = t.elapsed();
7331 eprintln!("v chain {len}: parsed and evaluated in {took:?}");
7332 assert!(took.as_secs() < 30, "{took:?}");
7333 });
7334 }
7335
7336 /// gh#986 review item 9: a thread that declares no stack is assumed to
7337 /// have Rust's default thread stack (wasm: 1 MiB), not the CLI's 1 GiB.
7338 #[test]
7339 fn gh986_an_undeclared_stack_gets_the_default_assumption() {
7340 std::thread::spawn(|| {
7341 set_parse_stack_budget(None);
7342 assert_eq!(
7343 parse_depth_limit() as usize,
7344 DEFAULT_ASSUMED_STACK / PARSE_LEVEL_BYTES
7345 );
7346 assert!((parse_depth_limit() as usize) < MAX_PARSE_DEPTH as usize);
7347 })
7348 .join()
7349 .unwrap();
7350 }
7351
7352 /// gh#986: a left-deep `o0` chain of any length is read as one n-ary
7353 /// `Sum` — one level deep — so the issue's n = 20 000 (39 999 nested
7354 /// `o0`) parses, and so does 100k, with the same value as the nested
7355 /// binary tree would have had.
7356 #[test]
7357 fn gh986_long_o0_chain_is_read_as_a_flat_sum() {
7358 on_big_stack(|| {
7359 for n in [5_000usize, 20_000, 100_000] {
7360 let prob = parse_nl_text(&deep_sum_nl(n, 0)).expect("a long o0 sum must parse");
7361 let x: Vec<f64> = (0..n).map(|i| (i % 7) as f64 * 0.1).collect();
7362 let want: f64 = x.iter().map(|v| v.exp() + v * v).sum();
7363 let tree = prob.obj_expr();
7364 assert!(expr_tree_depth(&tree, &Default::default()) < 16, "n = {n}");
7365 let got = eval_expr(&tree, &x);
7366 assert!((got - want).abs() <= 1e-9 * want.abs(), "{got} vs {want}");
7367 }
7368 });
7369 }
7370
7371 /// gh#986: the guard follows the stack the thread declares, so a thread
7372 /// with an ordinary 8 MiB stack refuses deep nesting cleanly — in an
7373 /// unoptimised build (the worst frame size) exactly as in an optimised
7374 /// one — instead of overflowing it. This is the reliability the
7375 /// `parse_nl_text` SIGBUS/SIGSEGV reports lacked.
7376 #[test]
7377 fn gh986_guard_follows_the_declared_stack() {
7378 let stack = 8 << 20;
7379 std::thread::Builder::new()
7380 .stack_size(stack)
7381 .spawn(move || {
7382 set_parse_stack_budget(Some(stack));
7383 assert_eq!(parse_depth_limit() as usize, stack / PARSE_LEVEL_BYTES);
7384 let msg = parse_nl_text(&deep_sum_nl(4, 20_000))
7385 .err()
7386 .expect("40 000 levels cannot fit 8 MiB");
7387 assert!(msg.contains("levels"), "{msg}");
7388 // and a shallow model on the same thread is fine
7389 assert!(parse_nl_text(&deep_sum_nl(4, 50)).is_ok());
7390 set_parse_stack_budget(None);
7391 })
7392 .unwrap()
7393 .join()
7394 .unwrap();
7395 }
7396
7397 /// gh#986: nesting that really is deep (negations) past the guard is
7398 /// refused with an `Err`, at any depth, instead of overflowing the stack;
7399 /// nesting just inside the guard still parses. Both on the stack the
7400 /// callers provide, in debug as in release — the guard's headroom is
7401 /// what this pins.
7402 #[test]
7403 fn gh986_deep_nesting_is_a_clean_error_past_the_guard_and_parses_inside_it() {
7404 on_big_stack(|| {
7405 let deep = parse_nl_text(&deep_sum_nl(4, 30_000));
7406 let msg = deep.err().expect("past the guard must be an Err");
7407 assert!(msg.contains("levels"), "{msg}");
7408 // Just inside the limit this thread's declared stack gives
7409 // (`MAX_PARSE_DEPTH` in an optimised build, 32 768 in an
7410 // unoptimised one, whose worst-case frame is 32 KiB).
7411 let wraps = (parse_depth_limit() as usize - 100) / 2;
7412 assert!(parse_nl_text(&deep_sum_nl(4, wraps)).is_ok());
7413 });
7414 }
7415
7416 use super::*;
7417
7418 /// Compile-time guarantee for the batched-solve path (pounce#126):
7419 /// a parsed problem and the TNLP built from it must be movable to a
7420 /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
7421 /// `!Send` state) into the `Expr` DAG / tape pipeline.
7422 #[test]
7423 fn nl_problem_and_tnlp_are_send() {
7424 fn assert_send<T: Send>() {}
7425 assert_send::<NlProblem>();
7426 assert_send::<NlTnlp>();
7427 assert_send::<Expr>();
7428 }
7429
7430 /// `variant()` patches starting point / bounds on a clone and
7431 /// validates override lengths; the base instance is untouched.
7432 #[test]
7433 fn variant_overrides_bounds_and_x0() {
7434 let p = parse_nl_text(SIMPLE).expect("parse");
7435 let base = NlTnlp::new(p);
7436 let var = base
7437 .variant(&NlVariation {
7438 x0: Some(vec![3.0, 4.0]),
7439 x_l: Some(vec![-1.0, -2.0]),
7440 x_u: Some(vec![5.0, 6.0]),
7441 ..Default::default()
7442 })
7443 .expect("variant");
7444 let mut var = var;
7445 let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
7446 let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
7447 assert!(var.get_bounds_info(BoundsInfo {
7448 x_l: &mut x_l,
7449 x_u: &mut x_u,
7450 g_l: &mut g_l,
7451 g_u: &mut g_u,
7452 }));
7453 assert_eq!(x_l, [-1.0, -2.0]);
7454 assert_eq!(x_u, [5.0, 6.0]);
7455 let mut x = [0.0; 2];
7456 let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
7457 assert!(var.get_starting_point(StartingPoint {
7458 init_x: true,
7459 x: &mut x,
7460 init_z: false,
7461 z_l: &mut zl,
7462 z_u: &mut zu,
7463 init_lambda: false,
7464 lambda: &mut lam,
7465 }));
7466 assert_eq!(x, [3.0, 4.0]);
7467 // Base keeps its parsed (free) bounds.
7468 assert!(base.problem().x_l[0] < -1.0e18);
7469 // Length mismatch is an error, not a panic.
7470 assert!(
7471 base.variant(&NlVariation {
7472 x0: Some(vec![1.0]),
7473 ..Default::default()
7474 })
7475 .is_err()
7476 );
7477 }
7478
7479 /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
7480 /// Header values:
7481 /// line 2: n=2 m=0 num_obj=1 0 0
7482 /// line 3: 0 1 (1 nonlinear objective)
7483 /// line 4: 0 0
7484 /// line 5: 0 2 0 (nonlinear vars in obj=2)
7485 /// line 6: 0 0 0 1
7486 /// line 7: 0 0 0 0 0
7487 /// line 8: 0 0 (no Jacobian nonzeros, no linear obj)
7488 /// line 9: 0 0
7489 /// line 10: 0 0 0 0 0
7490 /// Then `O0 0` followed by an expression tree:
7491 /// `(x0 - 1)^2 + (x1 - 2)^2` =
7492 /// o0
7493 /// o5 (o1 v0 n1) n2
7494 /// o5 (o1 v1 n2) n2
7495 /// Then `b` segment: free for both.
7496 /// gh#987: Gay's ordering places nonlinear integers at the END of each
7497 /// nonlinear block and linear ones at the end of the model.
7498 #[test]
7499 fn discrete_census_locates_nonlinear_and_linear_integer_columns() {
7500 // n = 10: nlvb = 2 (1 int), con-only 3 (1 int), obj-only 1 (1 int),
7501 // then linear, ending in nbv = 1, niv = 1.
7502 let d = DiscreteCensus {
7503 binary: 1,
7504 integer: 1,
7505 nl_both: 1,
7506 nl_cons: 1,
7507 nl_objs: 1,
7508 };
7509 let c = NlCounts {
7510 nl_cons: 1,
7511 nl_objs: 1,
7512 nl_vars_cons: 5,
7513 nl_vars_objs: 6,
7514 nl_vars_both: 2,
7515 };
7516 assert_eq!(d.total(), 5);
7517 assert_eq!(d.integer_columns(10, Some(c)), Some(vec![1, 4, 5, 8, 9]));
7518 // Without the line-5 census the nonlinear columns are unknowable.
7519 assert_eq!(d.integer_columns(10, None), None);
7520 // Linear-only integers need no census.
7521 let lin = DiscreteCensus {
7522 binary: 2,
7523 ..Default::default()
7524 };
7525 assert_eq!(lin.integer_columns(4, None), Some(vec![2, 3]));
7526 // Inconsistent counts: more nonlinear-in-both integers than columns.
7527 let bad = DiscreteCensus {
7528 nl_both: 3,
7529 ..Default::default()
7530 };
7531 assert_eq!(bad.integer_columns(10, Some(c)), None);
7532 }
7533
7534 const SIMPLE: &str = "g3 0 1 0
75352 0 1 0 0
75360 1
75370 0
75380 2 0
75390 0 0 1
75400 0 0 0 0
75410 0
75420 0
75430 0 0 0 0
7544O0 0
7545o0
7546o5
7547o1
7548v0
7549n1
7550n2
7551o5
7552o1
7553v1
7554n2
7555n2
7556b
75573
75583
7559";
7560
7561 #[test]
7562 fn parses_simple_quadratic() {
7563 let p = parse_nl_text(SIMPLE).expect("parse");
7564 assert_eq!(p.n, 2);
7565 assert_eq!(p.m, 0);
7566 assert_eq!(p.num_obj, 1);
7567 // f(0,0) = 1 + 4 = 5
7568 let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
7569 assert!((f - 5.0).abs() < 1e-12);
7570 // f(1,2) = 0
7571 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7572 assert!(f.abs() < 1e-12);
7573 }
7574
7575 #[test]
7576 fn gradient_matches_analytic() {
7577 let p = parse_nl_text(SIMPLE).expect("parse");
7578 let x = [0.5, 1.0];
7579 let mut g = [0.0_f64; 2];
7580 grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
7581 // d/dx0 = 2*(x0-1) = -1.0
7582 // d/dx1 = 2*(x1-2) = -2.0
7583 assert!((g[0] - (-1.0)).abs() < 1e-12);
7584 assert!((g[1] - (-2.0)).abs() < 1e-12);
7585 }
7586
7587 /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
7588 /// with global semantics so the presolve auxiliary-elimination
7589 /// safeguard actually engages. Pre-fix the default trait stub returned
7590 /// `false` and left the slice untouched, so a variable that is
7591 /// nonlinear in the objective but zero-gradient at the probe point
7592 /// could be wrongly eliminated.
7593 ///
7594 /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
7595 /// of the objective (NonLinear); x1 appears only in the linear part
7596 /// (Linear).
7597 #[test]
7598 fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
7599 // (x0 - 1)^2
7600 let obj_nl = Expr::Binary(
7601 BinOp::Pow,
7602 Box::new(Expr::Binary(
7603 BinOp::Sub,
7604 Box::new(Expr::Var(0)),
7605 Box::new(Expr::Const(1.0)),
7606 )),
7607 Box::new(Expr::Const(2.0)),
7608 );
7609 let prob = NlProblem {
7610 src: None,
7611 cse_bodies: Vec::new(),
7612 complementarity: Vec::new(),
7613 n: 2,
7614 m: 0,
7615 num_obj: 1,
7616 minimize: true,
7617 obj_nonlinear: NlBody::Tree(obj_nl),
7618 obj_linear: vec![(1, 3.0)],
7619 obj_constant: 0.0,
7620 con_nonlinear: vec![],
7621 con_linear: vec![],
7622 x_l: vec![f64::NEG_INFINITY; 2],
7623 x_u: vec![f64::INFINITY; 2],
7624 g_l: vec![],
7625 g_u: vec![],
7626 x0: vec![0.0; 2],
7627 lambda0: vec![],
7628 suffixes: NlSuffixes::default(),
7629 imported_funcs: vec![],
7630 ampl_options: vec![],
7631 nl_counts: None,
7632 n_discrete: None,
7633 var_names: vec![],
7634 con_names: vec![],
7635 };
7636 let mut tnlp = NlTnlp::new(prob);
7637 let mut types = vec![Linearity::Linear; 2];
7638 let ok = tnlp.get_variables_linearity(&mut types);
7639 // Pre-fix: default stub returns false (slice untouched).
7640 assert!(
7641 ok,
7642 "get_variables_linearity must report it filled the slice"
7643 );
7644 assert!(
7645 matches!(types[0], Linearity::NonLinear),
7646 "x0 is nonlinear in the objective"
7647 );
7648 assert!(
7649 matches!(types[1], Linearity::Linear),
7650 "x1 appears only in the linear part"
7651 );
7652 }
7653
7654 /// Objective-scoped linearity must NOT inherit constraint
7655 /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
7656 /// (constraint tape) but linear w.r.t. the objective, so the presolve
7657 /// H11 guard must not treat it as objective-coupled — that was the CI
7658 /// regression where every gas-network variable (nonlinear in the flow
7659 /// equations, absent from the linear objective) blocked Phase-0
7660 /// elimination.
7661 #[test]
7662 fn objective_variables_linearity_ignores_constraint_nonlinearity() {
7663 // x0^2
7664 let con_nl = Expr::Binary(
7665 BinOp::Pow,
7666 Box::new(Expr::Var(0)),
7667 Box::new(Expr::Const(2.0)),
7668 );
7669 let prob = NlProblem {
7670 src: None,
7671 cse_bodies: Vec::new(),
7672 complementarity: Vec::new(),
7673 n: 2,
7674 m: 1,
7675 num_obj: 1,
7676 minimize: true,
7677 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
7678 obj_linear: vec![(1, 3.0)],
7679 obj_constant: 0.0,
7680 con_nonlinear: vec![NlBody::Tree(con_nl)],
7681 con_linear: vec![vec![]],
7682 x_l: vec![f64::NEG_INFINITY; 2],
7683 x_u: vec![f64::INFINITY; 2],
7684 g_l: vec![4.0],
7685 g_u: vec![4.0],
7686 x0: vec![0.0; 2],
7687 lambda0: vec![0.0],
7688 suffixes: NlSuffixes::default(),
7689 imported_funcs: vec![],
7690 ampl_options: vec![],
7691 nl_counts: None,
7692 n_discrete: None,
7693 var_names: vec![],
7694 con_names: vec![],
7695 };
7696 let mut tnlp = NlTnlp::new(prob);
7697
7698 let mut global = vec![Linearity::Linear; 2];
7699 assert!(tnlp.get_variables_linearity(&mut global));
7700 assert!(
7701 matches!(global[0], Linearity::NonLinear),
7702 "global tags see x0's constraint nonlinearity"
7703 );
7704
7705 let mut obj = vec![Linearity::NonLinear; 2];
7706 assert!(tnlp.get_objective_variables_linearity(&mut obj));
7707 assert!(
7708 matches!(obj[0], Linearity::Linear),
7709 "x0 is linear w.r.t. the objective despite the nonlinear constraint"
7710 );
7711 assert!(
7712 matches!(obj[1], Linearity::Linear),
7713 "x1 is linear everywhere"
7714 );
7715 }
7716
7717 /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
7718 /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
7719 /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
7720 /// constraints, and both variables nonlinear in the objective only.
7721 #[test]
7722 fn header_census_is_parsed() {
7723 let p = parse_nl_text(SIMPLE).expect("parse");
7724 let c = p.nl_counts.expect("SIMPLE has a well-formed header");
7725 assert_eq!(c.nl_cons, 0);
7726 assert_eq!(c.nl_objs, 1);
7727 assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
7728 assert_eq!(c.nonlinear_vars(), 2);
7729 }
7730
7731 /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
7732 /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
7733 /// as the overlapping one: disjoint sets add, and `max` would be wrong
7734 /// for them.
7735 #[test]
7736 fn nonlinear_var_total_uses_inclusion_exclusion() {
7737 let c = |vc, vo, vb| NlCounts {
7738 nl_cons: 0,
7739 nl_objs: 0,
7740 nl_vars_cons: vc,
7741 nl_vars_objs: vo,
7742 nl_vars_both: vb,
7743 };
7744 // Fully shared: the same 5 variables in both.
7745 assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
7746 // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
7747 // not the `max(nlvc, nlvo) = 1` a naive reading gives.
7748 assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
7749 // Partial overlap: 4 + 3 - 2.
7750 assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
7751 // Nonsense header: saturates instead of underflowing.
7752 assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
7753 }
7754
7755 /// A header that does not carry the documented fields records no
7756 /// census at all rather than a guess of zero — "no nonlinear
7757 /// variables" is a claim, and a truncated header has not made it.
7758 #[test]
7759 fn short_header_line_records_no_census() {
7760 // Line 5 with two fields instead of `nlvc nlvo nlvb`.
7761 let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
7762 assert_ne!(txt, SIMPLE, "the substitution must have applied");
7763 let p = parse_nl_text(&txt).expect("parse");
7764 assert!(p.nl_counts.is_none());
7765 }
7766
7767 /// `get_number_of_nonlinear_variables` used to be the trait default,
7768 /// `-1` ("assume everything is nonlinear"). It now answers from the
7769 /// trees, and `get_list_of_nonlinear_variables` agrees with it.
7770 ///
7771 /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
7772 #[test]
7773 fn nonlinear_variable_list_excludes_linear_columns() {
7774 let obj_nl = Expr::Binary(
7775 BinOp::Pow,
7776 Box::new(Expr::Binary(
7777 BinOp::Sub,
7778 Box::new(Expr::Var(0)),
7779 Box::new(Expr::Const(1.0)),
7780 )),
7781 Box::new(Expr::Const(2.0)),
7782 );
7783 let parts = NlProblemParts {
7784 minimize: true,
7785 objective: obj_nl,
7786 obj_constant: 0.0,
7787 constraints: vec![],
7788 x_l: vec![-1e19; 2],
7789 x_u: vec![1e19; 2],
7790 x0: vec![0.0; 2],
7791 g_l: vec![],
7792 g_u: vec![],
7793 var_names: vec![],
7794 con_names: vec![],
7795 };
7796 let prob = NlProblem::from_expressions(parts).expect("build");
7797 assert!(
7798 prob.nl_counts.is_none(),
7799 "a model built in memory has no header to read"
7800 );
7801 let mut tnlp = NlTnlp::new(prob);
7802 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
7803 let mut list = [-1 as Index; 2];
7804 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7805 assert_eq!(list[0], 0);
7806 }
7807
7808 /// When the header says every variable is nonlinear the walk is
7809 /// skipped, and the answer is the same one the walk would give for
7810 /// `SIMPLE` (both variables appear in the objective's nonlinear part).
7811 #[test]
7812 fn all_nonlinear_header_short_circuits_to_n() {
7813 let p = parse_nl_text(SIMPLE).expect("parse");
7814 assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
7815 let mut tnlp = NlTnlp::new(p);
7816 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
7817 let mut list = [-1 as Index; 2];
7818 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7819 assert_eq!(list, [0, 1]);
7820 }
7821
7822 /// The list must not be written when the caller's slice is too small —
7823 /// the contract's `false` return, not a panic.
7824 #[test]
7825 fn nonlinear_variable_list_declines_a_short_slice() {
7826 let p = parse_nl_text(SIMPLE).expect("parse");
7827 let mut tnlp = NlTnlp::new(p);
7828 let mut list = [-1 as Index; 1];
7829 assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
7830 assert_eq!(list, [-1]);
7831 }
7832
7833 /// `min x0^2 + x1^2 s.t. x0 + x1 = 1`.
7834 /// One equality constraint with a purely linear Jacobian — exercises
7835 /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
7836 /// kind 4).
7837 ///
7838 /// Header layout:
7839 /// line 1: g3 0 1 0
7840 /// line 2: 2 1 1 0 0 (n=2, m=1, num_obj=1)
7841 /// line 3: 0 1 (1 nonlinear obj, 0 nonlinear cons)
7842 /// line 4: 0 0
7843 /// line 5: 0 2 0 (nonlinear vars in obj=2)
7844 /// line 6: 0 0 0 1
7845 /// line 7: 0 0 0 0 0
7846 /// line 8: 2 0 (Jacobian nnz=2, no linear obj)
7847 /// line 9: 0 0
7848 /// line 10: 0 0 0 0 0
7849 /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
7850 /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
7851 const EQ_LIN: &str = "g3 0 1 0
78522 1 1 0 0
78530 1
78540 0
78550 2 0
78560 0 0 1
78570 0 0 0 0
78582 0
78590 0
78600 0 0 0 0
7861C0
7862n0
7863O0 0
7864o0
7865o5
7866v0
7867n2
7868o5
7869v1
7870n2
7871r
78724 1
7873b
78743
78753
7876k1
78772
7878J0 2
78790 1
78801 1
7881";
7882
7883 #[test]
7884 fn parses_constrained_problem() {
7885 let p = parse_nl_text(EQ_LIN).expect("parse");
7886 assert_eq!(p.n, 2);
7887 assert_eq!(p.m, 1);
7888 // r-segment kind 4 (equality with rhs=1).
7889 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7890 assert!((p.g_u[0] - 1.0).abs() < 1e-12);
7891 // J-row 0: x0 (coef 1), x1 (coef 1).
7892 assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
7893 }
7894
7895 #[test]
7896 fn malformed_j_variable_index_is_parse_error_not_panic() {
7897 // Code review L32: a J-segment entry's variable (column) index was
7898 // pushed into con_linear unchecked, so an out-of-range index (here 5
7899 // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
7900 // constraint evaluation. It must instead surface as a clean parse
7901 // error, consistent with the existing `J<row> out of range` check.
7902 let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
7903 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7904 let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
7905 assert!(err.contains("out of range"), "unexpected error: {err}");
7906 }
7907
7908 #[test]
7909 fn out_of_range_x_segment_index_is_parse_error() {
7910 // Same strictness for the initial-primal `x` segment: an index past
7911 // `n` used to be silently dropped; now it is a parse error, so the
7912 // four index-bearing segments (J/G/x/d) behave consistently.
7913 let bad = format!("{EQ_LIN}x1\n5 0.5\n");
7914 let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
7915 assert!(err.contains("out of range"), "unexpected error: {err}");
7916 }
7917
7918 // ---------------------------------------------------------------
7919 // gh #492 — a constant `C<i>` body folds into the row bounds.
7920 //
7921 // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
7922 // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
7923 // constant-body rows to fold.
7924 // ---------------------------------------------------------------
7925
7926 /// Linearity is a *linearity* test, not "did a `C` segment touch this
7927 /// row". A row whose body is the bare constant `3` is affine.
7928 #[test]
7929 fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
7930 // `x0 + x1 + 3 = 1` ⇔ `x0 + x1 = -2`.
7931 let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7932 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7933 let p = parse_nl_text(&nl).expect("parse");
7934
7935 assert!(
7936 p.con_nonlinear[0].is_trivially_zero(),
7937 "the constant body must be replaced by the identity zero, got {:?}",
7938 p.con_nonlinear[0]
7939 );
7940 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7941 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7942
7943 let mut lin = [Linearity::NonLinear];
7944 let mut t = NlTnlp::new(p);
7945 assert!(t.get_constraints_linearity(&mut lin));
7946 assert_eq!(lin[0], Linearity::Linear);
7947 }
7948
7949 /// The shift must be exact, not merely "linear now": the folded model
7950 /// and the hand-folded one must be the same problem, row body for row
7951 /// body and bound for bound. That is what keeps feasibility, the
7952 /// active set, and the duals unchanged.
7953 #[test]
7954 fn folding_a_row_constant_gives_the_hand_folded_problem() {
7955 // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
7956 let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7957 let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
7958 assert_ne!(offset, EQ_LIN);
7959 assert_ne!(folded, EQ_LIN);
7960
7961 let a = parse_nl_text(&offset).expect("parse offset form");
7962 let b = parse_nl_text(&folded).expect("parse folded form");
7963 assert_eq!(a.g_l, b.g_l);
7964 assert_eq!(a.g_u, b.g_u);
7965 assert_eq!(a.con_linear, b.con_linear);
7966
7967 // And the row *values* agree pointwise, which is the property the
7968 // duals ride on: `g(x) - g_l` is the same residual either way.
7969 let mut ga = [0.0];
7970 let mut gb = [0.0];
7971 let x = [0.75, -1.25];
7972 assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
7973 assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
7974 assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
7975 }
7976
7977 /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
7978 /// constant as `n3`, and AMPL emits such trees when a expression
7979 /// collapses without being re-simplified.
7980 #[test]
7981 fn a_row_body_that_evaluates_to_a_constant_folds_too() {
7982 // `C0` = `1 + 2`.
7983 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
7984 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7985 let p = parse_nl_text(&nl).expect("parse");
7986 assert!(p.con_nonlinear[0].is_trivially_zero());
7987 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7988 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7989 }
7990
7991 /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
7992 /// "absent", not "a very large number". Shifting one turns it into a
7993 /// real bound and invents a constraint that is not in the model.
7994 ///
7995 /// The constants here are deliberately huge. An everyday `3` is
7996 /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
7997 /// presence guard would go unnoticed at ordinary magnitudes and then
7998 /// bite on a model that scales its rows. The guard is what makes the
7999 /// sentinel untouchable at *any* magnitude.
8000 #[test]
8001 fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
8002 // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
8003 // bound. Shifting the lower sentinel would leave `-9e18`, a real
8004 // bound, so the row would gain a floor the model never stated.
8005 let nl = EQ_LIN
8006 .replace("C0\nn0\n", "C0\nn-1e18\n")
8007 .replace("r\n4 1\n", "r\n1 1\n");
8008 let p = parse_nl_text(&nl).expect("parse");
8009 assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
8010 assert!(
8011 !lower_bound_present(p.g_l[0]),
8012 "the absent-lower sentinel became a real bound: {}",
8013 p.g_l[0]
8014 );
8015
8016 // The mirror case: a positive constant on a lower-bounded row is
8017 // the one that would pull the *upper* sentinel below 1e19.
8018 let nl = EQ_LIN
8019 .replace("C0\nn0\n", "C0\nn1e18\n")
8020 .replace("r\n4 1\n", "r\n2 1\n");
8021 let p = parse_nl_text(&nl).expect("parse");
8022 assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
8023 assert!(
8024 !upper_bound_present(p.g_u[0]),
8025 "the absent-upper sentinel became a real bound: {}",
8026 p.g_u[0]
8027 );
8028 }
8029
8030 /// A row body that mentions a variable is not a constant, however
8031 /// simple it looks. Folding it would delete the term.
8032 #[test]
8033 fn a_row_body_with_a_variable_is_not_folded() {
8034 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
8035 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8036 let p = parse_nl_text(&nl).expect("parse");
8037 assert!(
8038 !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
8039 "a row in x0 was folded away: {:?}",
8040 p.con_nonlinear[0]
8041 );
8042 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8043 assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
8044 }
8045
8046 /// A variable-free body whose value is not finite is left in place. It
8047 /// makes the row infeasible (or ill-posed) and that is the solver's
8048 /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
8049 /// corrupt the bound pair and take every downstream presence test with
8050 /// it.
8051 #[test]
8052 fn a_non_finite_constant_row_body_is_not_folded() {
8053 // `C0` = `log(-1)` = NaN.
8054 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
8055 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8056 let p = parse_nl_text(&nl).expect("parse");
8057 assert!(
8058 !p.con_nonlinear[0].is_trivially_zero(),
8059 "a NaN body was folded into the bounds"
8060 );
8061 assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
8062 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
8063 }
8064
8065 /// An imported-function call is not a parse-time constant even with
8066 /// constant arguments — it is resolved to a shared library much later
8067 /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
8068 /// rather than guess. The fold must decline before it evaluates.
8069 #[test]
8070 fn a_constant_argument_funcall_row_body_is_not_folded() {
8071 // Declare one imported function and call it with a literal.
8072 let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
8073 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8074 let p = parse_nl_text(&nl).expect("parse");
8075 assert!(
8076 matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
8077 "expected the funcall to survive the fold, got {:?}",
8078 p.con_nonlinear[0]
8079 );
8080 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8081 }
8082
8083 #[test]
8084 fn k_segment_nonstandard_count_is_parse_error_at_source() {
8085 // Code review L35: the `k` (Jacobian column-count) segment header
8086 // declares how many count lines follow — `k<count>` — and the
8087 // standard value is n-1. The parser used to *assume* n-1 and ignore
8088 // the header, so a file declaring a different count read the wrong
8089 // number of data lines, desynced the segment stream, and failed far
8090 // downstream with a confusing error (or silently mis-parsed). With
8091 // the declared count now read and validated, a nonstandard count is
8092 // a clear parse error at its source. Here EQ_LIN has n=2 (expected
8093 // count 1); rewrite its `k1` + one count line to `k0`.
8094 let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
8095 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
8096 let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
8097 assert!(
8098 err.contains("k-segment declares"),
8099 "expected a clear k-segment count error, got: {err}"
8100 );
8101 }
8102
8103 #[test]
8104 fn get_starting_point_returns_nl_initial_duals() {
8105 // Code review 2026-06 item M19: the `.nl` `d` segment supplies
8106 // initial constraint multipliers. They are parsed into `lambda0`,
8107 // but `get_starting_point` previously ignored them — so a
8108 // `warm_start_init_point yes` solve silently began from zero duals.
8109 // `get_starting_point` must hand the parsed duals back when the
8110 // engine requests them (`init_lambda`), and leave the buffer
8111 // untouched when it does not.
8112 let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
8113 let p = parse_nl_text(&nl).expect("parse");
8114 // gh#1033: a `d` entry is an AMPL dual, so it is stored negated.
8115 assert_eq!(p.lambda0, vec![-2.5], "the `d` segment fills lambda0");
8116
8117 let mut t = NlTnlp::new(p);
8118 let info = t.get_nlp_info().unwrap();
8119 let (n, m) = (info.n as usize, info.m as usize);
8120
8121 // Warm-start request: init_lambda = true → the parsed `.nl` duals
8122 // must be returned (pre-fix this stayed zero).
8123 let mut x = vec![0.0; n];
8124 let mut z_l = vec![0.0; n];
8125 let mut z_u = vec![0.0; n];
8126 let mut lambda = vec![0.0; m];
8127 assert!(t.get_starting_point(StartingPoint {
8128 init_x: true,
8129 x: &mut x,
8130 init_z: false,
8131 z_l: &mut z_l,
8132 z_u: &mut z_u,
8133 init_lambda: true,
8134 lambda: &mut lambda,
8135 }));
8136 assert_eq!(
8137 lambda,
8138 vec![-2.5],
8139 "a warm start must use the `.nl` initial duals, not zero"
8140 );
8141
8142 // No warm-start request: the multiplier buffer is left alone (the
8143 // engine owns its default), so honoring the flag does not clobber it.
8144 let mut lambda_untouched = vec![7.0; m];
8145 assert!(t.get_starting_point(StartingPoint {
8146 init_x: true,
8147 x: &mut x,
8148 init_z: false,
8149 z_l: &mut z_l,
8150 z_u: &mut z_u,
8151 init_lambda: false,
8152 lambda: &mut lambda_untouched,
8153 }));
8154 assert_eq!(
8155 lambda_untouched,
8156 vec![7.0],
8157 "without init_lambda the multiplier buffer must be untouched"
8158 );
8159 }
8160
8161 /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
8162 /// unconstrained, `n` variables. The trailing product puts a nonzero
8163 /// in row 0 of *every* Hessian column, which is the shape that used
8164 /// to force the greedy coloring to hand out one color per variable.
8165 fn dense_row_objective_nl(n: usize) -> String {
8166 let mut s = String::new();
8167 s.push_str("g3 1 1 0\n");
8168 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8169 s.push_str(" 0 1\n 0 0\n");
8170 s.push_str(&format!(" {n} {n} {n}\n"));
8171 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8172 s.push_str(&format!(" 0 {n}\n"));
8173 s.push_str(" 0 0\n 0 0 0 0 0\n");
8174 // objective
8175 s.push_str("O0 0\n");
8176 s.push_str(&format!("o54\n{}\n", n + 1));
8177 for j in 0..n {
8178 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8179 }
8180 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8181 for j in 0..n {
8182 s.push_str(&format!("v{j}\n"));
8183 }
8184 // start, bounds, gradient
8185 s.push_str(&format!("x{n}\n"));
8186 for j in 0..n {
8187 s.push_str(&format!("{j} 0.5\n"));
8188 }
8189 s.push_str("b\n");
8190 for _ in 0..n {
8191 s.push_str("3\n");
8192 }
8193 s.push_str(&format!("G0 {n}\n"));
8194 for j in 0..n {
8195 s.push_str(&format!("{j} 0.0\n"));
8196 }
8197 s
8198 }
8199
8200 /// Same shape as [`dense_row_objective_nl`], but the coupling term
8201 /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
8202 /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
8203 /// magnitude makes the dense column ill-scaled — the case where
8204 /// reading its entries out of its own pass costs real digits.
8205 fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
8206 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8207 let mut s = String::new();
8208 s.push_str("g3 1 1 0\n");
8209 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8210 s.push_str(" 0 1\n 0 0\n");
8211 s.push_str(&format!(" {n} {n} {n}\n"));
8212 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8213 s.push_str(&format!(" 0 {n}\n"));
8214 s.push_str(" 0 0\n 0 0 0 0 0\n");
8215 s.push_str("O0 0\n");
8216 s.push_str(&format!("o54\n{}\n", n + 1));
8217 for j in 0..n {
8218 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8219 }
8220 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8221 for j in 0..n {
8222 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8223 }
8224 s.push_str(&format!("x{n}\n"));
8225 for j in 0..n {
8226 s.push_str(&format!("{j} 0.5\n"));
8227 }
8228 s.push_str("b\n");
8229 for _ in 0..n {
8230 s.push_str("3\n");
8231 }
8232 s.push_str(&format!("G0 {n}\n"));
8233 for j in 0..n {
8234 s.push_str(&format!("{j} 0.0\n"));
8235 }
8236 s
8237 }
8238
8239 /// Locate a model in the benchmark corpus, or `None` if the corpus is
8240 /// not on this machine.
8241 ///
8242 /// The corpus is ~2 GB and deliberately outside the checkout (see
8243 /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
8244 /// no-op rather than fail. That is a real limitation — a check that
8245 /// silently does nothing is how the gap below went unnoticed in the
8246 /// first place — so anything using this must also be covered by a
8247 /// synthetic case that always runs.
8248 fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
8249 let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
8250 let p = std::path::PathBuf::from(root).join(rel);
8251 p.is_file().then_some(p)
8252 }
8253
8254 /// The corpus check the dense-column optimization never got.
8255 ///
8256 /// `cho_parmest` is the model whose certificate the optimization cost,
8257 /// and it could not have caught it: the model is 4.3 MB and lives in
8258 /// the benchmark data set, not in the repository, so validating
8259 /// against the in-repo `.nl` fixtures said nothing about it. This test
8260 /// closes that by checking the corpus directly wherever the corpus
8261 /// exists, which is every machine and CI job that runs the benchmarks.
8262 ///
8263 /// The assertion is the one that matters and the one that was never
8264 /// made: whatever the guard leaves peeled must decode to the same
8265 /// Hessian as peeling nothing. Against the pre-fix code this fails —
8266 /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
8267 #[test]
8268 fn cho_parmest_decodes_to_its_unpeeled_reference() {
8269 let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
8270 eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
8271 return;
8272 };
8273 let p = read_nl_file(&path).expect("read cho_parmest");
8274 let n = p.n;
8275 let mut t = NlTnlp::new(p);
8276 // The guard vetoes 7 of cho's 12 peeled columns, and putting those
8277 // seven dense rows back into the conflict graph costs the coloring
8278 // outright: it goes from 17 colors to 9010, and no column clears the
8279 // density threshold afterwards, so the model ends up fully unpeeled.
8280 // That is the price of the certificate on this model, and it is worth
8281 // knowing rather than assuming the other five survive.
8282 assert!(t.peeled_cols.is_empty());
8283
8284 let info = t.get_nlp_info().unwrap();
8285 let nnz = info.nnz_h_lag as usize;
8286 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8287 assert!(t.eval_h(
8288 None,
8289 true,
8290 1.0,
8291 None,
8292 true,
8293 SparsityRequest::Structure {
8294 irow: &mut irow,
8295 jcol: &mut jcol
8296 }
8297 ));
8298 // Evaluate away from `x0` with non-uniform multipliers. The damage
8299 // is point-dependent, and `x0` is close to where it is least
8300 // visible: only 5 entries move there, against 48,931 here. Note the
8301 // guard's own probe runs at `x0` — it fires anyway because it tests
8302 // a bound on what a pass *could* lose, not the damage it happens to
8303 // commit at one point. That is the property that makes it robust,
8304 // and this asymmetry is worth keeping in front of anyone who
8305 // retunes it.
8306 let x: Vec<f64> = t
8307 .prob
8308 .x0
8309 .iter()
8310 .enumerate()
8311 .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
8312 .collect();
8313 let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
8314
8315 let mut got = vec![0.0_f64; nnz];
8316 assert!(t.eval_h(
8317 Some(&x),
8318 true,
8319 1.0,
8320 Some(&lambda),
8321 true,
8322 SparsityRequest::Values { values: &mut got }
8323 ));
8324
8325 t.recolor(&vec![true; n]);
8326 assert!(t.peeled_cols.is_empty());
8327 let mut want = vec![0.0_f64; nnz];
8328 assert!(t.eval_h(
8329 Some(&x),
8330 true,
8331 1.0,
8332 Some(&lambda),
8333 true,
8334 SparsityRequest::Values { values: &mut want }
8335 ));
8336
8337 let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
8338 let mut worst = 0.0_f64;
8339 let mut at = 0usize;
8340 for k in 0..nnz {
8341 let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8342 if rel > worst {
8343 worst = rel;
8344 at = k;
8345 }
8346 }
8347 assert!(
8348 worst <= 1e-13,
8349 "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
8350 {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
8351 out of a pass it cannot be read out of.",
8352 irow[at],
8353 jcol[at],
8354 got[at],
8355 want[at]
8356 );
8357
8358 // The check above passes trivially on correct code, because the
8359 // guard leaves cho fully unpeeled and so compares a configuration
8360 // against itself. It only has teeth against a regression. So prove
8361 // the guard is doing necessary work rather than assuming it: put
8362 // the peels back the way the pre-fix reader had them and confirm
8363 // the Hessian really does come apart.
8364 t.recolor(&vec![false; n]);
8365 assert!(
8366 !t.peeled_cols.is_empty(),
8367 "restoring the unvetoed coloring must peel again"
8368 );
8369 let mut unguarded = vec![0.0_f64; nnz];
8370 assert!(t.eval_h(
8371 Some(&x),
8372 true,
8373 1.0,
8374 Some(&lambda),
8375 true,
8376 SparsityRequest::Values {
8377 values: &mut unguarded
8378 }
8379 ));
8380 let mut bad = 0usize;
8381 let mut worst_unguarded = 0.0_f64;
8382 for k in 0..nnz {
8383 let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8384 if rel > 1e-13 {
8385 bad += 1;
8386 }
8387 worst_unguarded = worst_unguarded.max(rel);
8388 }
8389 // Two different counts get quoted about this model and they measure
8390 // different things: 48,931 of the 96,000 entries differ from the
8391 // uncompressed reference *at all*, down to the last bit, while 88
8392 // exceed 1e-13 relative. The second is the one worth asserting on.
8393 assert!(
8394 bad >= 50 && worst_unguarded > 1e-11,
8395 "peeling cho_parmest unguarded should damage the entries the guard \
8396 exists to protect (measured: 88 entries past 1e-13 relative, \
8397 worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
8398 this fires, the model or the corpus changed and the guard's \
8399 calibration should be re-derived rather than the bound relaxed."
8400 );
8401 eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
8402 }
8403
8404 /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
8405 /// the dense variable is `x_{n-1}` instead of `x_0`:
8406 /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
8407 ///
8408 /// The index matters, and it is the whole reason this helper exists.
8409 /// With the dense variable at 0 every coupling entry is stored as
8410 /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
8411 /// out of column 0's own pass whether or not peeling is on, so the two
8412 /// paths are bit-identical and no test built on that shape can tell
8413 /// them apart. Putting the dense variable last stores them as
8414 /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
8415 /// back from column `n-1`'s pass by symmetry, carrying that pass's
8416 /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
8417 /// That is the category every one of `cho_parmest`'s 48,931 damaged
8418 /// entries fell into.
8419 fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
8420 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
8421 let d = n - 1;
8422 let mut s = String::new();
8423 s.push_str("g3 1 1 0\n");
8424 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8425 s.push_str(" 0 1\n 0 0\n");
8426 s.push_str(&format!(" {n} {n} {n}\n"));
8427 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8428 s.push_str(&format!(" 0 {n}\n"));
8429 s.push_str(" 0 0\n 0 0 0 0 0\n");
8430 s.push_str("O0 0\n");
8431 s.push_str(&format!("o54\n{}\n", n + 1));
8432 for j in 0..n {
8433 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8434 }
8435 s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
8436 for j in 0..d {
8437 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8438 }
8439 s.push_str(&format!("x{n}\n"));
8440 for j in 0..n {
8441 s.push_str(&format!("{j} 0.5\n"));
8442 }
8443 s.push_str("b\n");
8444 for _ in 0..n {
8445 s.push_str("3\n");
8446 }
8447 s.push_str(&format!("G0 {n}\n"));
8448 for j in 0..n {
8449 s.push_str(&format!("{j} 0.0\n"));
8450 }
8451 s
8452 }
8453
8454 /// A well-scaled dense column is still peeled, and the entries read
8455 /// out of its pass are exact — the property the peel guard must not
8456 /// cost us.
8457 #[test]
8458 fn a_well_scaled_dense_column_is_still_peeled() {
8459 let n = 200;
8460 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
8461 let t = NlTnlp::new(p);
8462 assert_eq!(
8463 t.peeled_cols,
8464 vec![0],
8465 "a dense column that costs no accuracy must stay peeled"
8466 );
8467 assert!(
8468 t.seeds.len() <= 4,
8469 "peeling should keep the color count at O(1), got {}",
8470 t.seeds.len()
8471 );
8472 }
8473
8474 /// An ill-scaled dense column must be un-peeled and colored the
8475 /// ordinary way, so its small entries come back to full precision.
8476 ///
8477 /// This test covers the *guard*, not the damage. It asserts that a
8478 /// column spanning 12 orders is un-peeled and that its entries are then
8479 /// exact — and it is worth being precise that it does not, and cannot,
8480 /// show what peeling would have cost, because on this model peeling
8481 /// costs nothing: force the peel through and every entry still comes
8482 /// back bit-identical to its analytic weight. The Hessian here is
8483 /// constant and each entry is a single product, so there is no
8484 /// accumulation for a large-magnitude pass to pollute.
8485 ///
8486 /// Reproducing the actual digit loss takes a model whose entries are
8487 /// summed through shared intermediates, which is why the demonstration
8488 /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
8489 /// real model rather than a synthetic one.
8490 #[test]
8491 fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
8492 let n = 200;
8493 let span = 12.0;
8494 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8495 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
8496 let mut t = NlTnlp::new(p);
8497
8498 assert!(
8499 t.peeled_cols.is_empty(),
8500 "a dense column spanning {span} orders must not be peeled; got {:?}",
8501 t.peeled_cols
8502 );
8503
8504 let info = t.get_nlp_info().unwrap();
8505 let nnz = info.nnz_h_lag as usize;
8506 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8507 assert!(t.eval_h(
8508 None,
8509 true,
8510 1.0,
8511 None,
8512 true,
8513 SparsityRequest::Structure {
8514 irow: &mut irow,
8515 jcol: &mut jcol
8516 }
8517 ));
8518 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8519 let mut vals = vec![0.0_f64; nnz];
8520 assert!(t.eval_h(
8521 Some(&x),
8522 true,
8523 1.0,
8524 None,
8525 true,
8526 SparsityRequest::Values { values: &mut vals }
8527 ));
8528
8529 // Every coupling entry must be its weight to full relative
8530 // precision. Peeled, the smallest of them came back with a
8531 // relative error near 1e-4.
8532 let mut checked = 0;
8533 for k in 0..nnz {
8534 let (i, j) = (irow[k] as usize, jcol[k] as usize);
8535 if i == j {
8536 continue;
8537 }
8538 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8539 checked += 1;
8540 let want = w(i);
8541 assert!(
8542 (vals[k] - want).abs() <= 1e-13 * want,
8543 "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
8544 vals[k],
8545 (vals[k] - want).abs() / want
8546 );
8547 }
8548 assert_eq!(checked, n - 1);
8549 }
8550
8551 /// Whatever survives the peel guard must decode to the same Hessian
8552 /// that not peeling at all produces — every entry, not just the
8553 /// objective.
8554 ///
8555 /// This exists because the original dense-column optimization was
8556 /// validated by running the repository's `.nl` fixtures and comparing
8557 /// objective value and exit status, which was doubly blind: not one of
8558 /// the 60 fixtures has a Hessian row dense enough to peel anything, so
8559 /// the decode path under test never executed, and even had it executed,
8560 /// the defect cost digits in the multipliers while leaving the
8561 /// objective intact. So the instrument has to be the assembled Hessian
8562 /// and the input has to actually peel — hence `peeled_any` below, which
8563 /// fails if the sweep ever goes vacuous the way the fixture suite
8564 /// silently did.
8565 ///
8566 /// What this catches is a *decode* fault: an entry recovered from the
8567 /// wrong pass, which is wrong by O(1). It would not have caught the
8568 /// `cho_parmest` stall, because these synthetic models lose no
8569 /// precision under peeling at all (see
8570 /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
8571 /// precision half is covered against the real model in
8572 /// `cho_parmest_decodes_to_its_unpeeled_reference`.
8573 #[test]
8574 fn a_peeled_decode_matches_an_unpeeled_reference() {
8575 // `last = true` puts the dense variable at `n-1`, so the coupling
8576 // entries are stored with the peeled column as their *row* — the
8577 // only shape in which peeling and not peeling read an entry out of
8578 // different passes, and so the only shape that can detect a
8579 // difference at all. See `weighted_dense_last_col_nl`.
8580 let cases = [
8581 (200, 0.0, false),
8582 (200, 2.0, false),
8583 (200, 0.0, true),
8584 (200, 1.0, true),
8585 (200, 2.0, true),
8586 (400, 3.0, true),
8587 (600, 0.0, true),
8588 ];
8589 let mut peeled_any = false;
8590 let mut worst = 0.0_f64;
8591
8592 for &(n, span, last) in &cases {
8593 let text = if last {
8594 weighted_dense_last_col_nl(n, span)
8595 } else {
8596 weighted_dense_row_objective_nl(n, span)
8597 };
8598 let p = parse_nl_text(&text).expect("parse");
8599 let mut t = NlTnlp::new(p);
8600 let peeled = t.peeled_cols.clone();
8601 peeled_any |= !peeled.is_empty();
8602
8603 let info = t.get_nlp_info().unwrap();
8604 let nnz = info.nnz_h_lag as usize;
8605 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8606 assert!(t.eval_h(
8607 None,
8608 true,
8609 1.0,
8610 None,
8611 true,
8612 SparsityRequest::Structure {
8613 irow: &mut irow,
8614 jcol: &mut jcol
8615 }
8616 ));
8617 let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
8618
8619 let mut got = vec![0.0_f64; nnz];
8620 assert!(t.eval_h(
8621 Some(&x),
8622 true,
8623 1.0,
8624 None,
8625 true,
8626 SparsityRequest::Values { values: &mut got }
8627 ));
8628
8629 // Same object, same tapes, same point — only the coloring
8630 // differs, so any disagreement is the decode path.
8631 t.recolor(&vec![true; n]);
8632 assert!(
8633 t.peeled_cols.is_empty(),
8634 "a fully vetoed model must peel nothing"
8635 );
8636 let mut want = vec![0.0_f64; nnz];
8637 assert!(t.eval_h(
8638 Some(&x),
8639 true,
8640 1.0,
8641 None,
8642 true,
8643 SparsityRequest::Values { values: &mut want }
8644 ));
8645
8646 for k in 0..nnz {
8647 let scale = want[k].abs().max(f64::MIN_POSITIVE);
8648 let rel = (got[k] - want[k]).abs() / scale;
8649 worst = worst.max(rel);
8650 assert!(
8651 rel <= 1e-13,
8652 "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
8653 unpeeled reference {:e} (relative error {rel:e})",
8654 irow[k],
8655 jcol[k],
8656 got[k],
8657 want[k]
8658 );
8659 }
8660 }
8661
8662 assert!(
8663 peeled_any,
8664 "no case peeled anything, so this test proved nothing about the \
8665 decode path — the exact way the fixture suite missed the bug"
8666 );
8667 assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
8668 }
8669
8670 /// `peel_veto` bars a column from the peel set, and the row it puts
8671 /// back into the conflict structure costs the colors it used to save.
8672 #[test]
8673 fn a_vetoed_column_is_colored_the_ordinary_way() {
8674 let n = 300;
8675 // One dense row plus a diagonal: column 0 touches every row.
8676 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8677 pairs.extend((1..n).map(|i| (i, 0)));
8678 pairs.sort_unstable();
8679
8680 let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8681 assert!(peeled[0], "the dense column should peel by default");
8682 assert!(
8683 colors_peeled <= 4,
8684 "peeling should collapse the count, got {colors_peeled}"
8685 );
8686
8687 let mut veto = vec![false; n];
8688 veto[0] = true;
8689 let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
8690 assert!(!peeled[0], "a vetoed column must not be peeled");
8691 assert!(
8692 colors_vetoed > colors_peeled,
8693 "un-peeling restores row 0's conflicts, so colors must rise: \
8694 {colors_vetoed} vs {colors_peeled}"
8695 );
8696 }
8697
8698 /// A single dense Hessian row must not blow the coloring up to one
8699 /// color per variable. It used to: every column shares row 0, so no
8700 /// two columns could be colored alike, and `seeds` / `compressed`
8701 /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
8702 /// per `eval_h` on a Hessian holding only ~2n nonzeros.
8703 #[test]
8704 fn a_dense_hessian_row_does_not_explode_the_coloring() {
8705 let n = 200;
8706 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8707 let t = NlTnlp::new(p);
8708 // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
8709 // for j >= 1 from the coupling term.
8710 assert_eq!(t.h_irow.len(), 2 * n - 1);
8711 assert!(
8712 t.seeds.len() <= 4,
8713 "one dense row should cost one extra color, not n; got {} colors for n={n}",
8714 t.seeds.len()
8715 );
8716 assert_eq!(t.seeds.len(), t.compressed.len());
8717 }
8718
8719 /// Peeling changes *which* directional product recovers an entry, so
8720 /// the recovered Hessian must still be exactly right — including the
8721 /// entries read out of a peeled column's pass by symmetry.
8722 #[test]
8723 fn peeled_dense_column_still_recovers_the_exact_hessian() {
8724 let n = 200;
8725 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8726 let mut t = NlTnlp::new(p);
8727 let info = t.get_nlp_info().unwrap();
8728 let nnz = info.nnz_h_lag as usize;
8729
8730 let mut irow = vec![0_i32; nnz];
8731 let mut jcol = vec![0_i32; nnz];
8732 assert!(t.eval_h(
8733 None,
8734 true,
8735 1.0,
8736 None,
8737 true,
8738 SparsityRequest::Structure {
8739 irow: &mut irow,
8740 jcol: &mut jcol
8741 }
8742 ));
8743
8744 // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
8745 // H[0,0] = 2 + 2 = 4; H[j,j] = 2 (j >= 1); H[j,0] = 1 (j >= 1).
8746 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8747 let obj_factor = 2.5;
8748 let mut vals = vec![0.0_f64; nnz];
8749 assert!(t.eval_h(
8750 Some(&x),
8751 true,
8752 obj_factor,
8753 None,
8754 true,
8755 SparsityRequest::Values { values: &mut vals }
8756 ));
8757
8758 let mut seen_diag = 0;
8759 let mut seen_coupling = 0;
8760 for k in 0..nnz {
8761 let (i, j) = (irow[k] as usize, jcol[k] as usize);
8762 let want = if i == 0 && j == 0 {
8763 4.0
8764 } else if i == j {
8765 seen_diag += 1;
8766 2.0
8767 } else {
8768 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8769 seen_coupling += 1;
8770 1.0
8771 } * obj_factor;
8772 assert!(
8773 (vals[k] - want).abs() < 1e-12,
8774 "H[{i},{j}] = {}, want {want}",
8775 vals[k]
8776 );
8777 }
8778 assert_eq!(seen_diag, n - 1);
8779 assert_eq!(seen_coupling, n - 1);
8780 }
8781
8782 /// The peeling threshold must leave ordinary sparse models on
8783 /// exactly the coloring they had before: a banded Hessian colors by
8784 /// its bandwidth, with nothing peeled.
8785 #[test]
8786 fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
8787 let n = 400;
8788 let pairs: Vec<(usize, usize)> = (0..n)
8789 .flat_map(|j| {
8790 let mut v = vec![(j, j)];
8791 if j + 1 < n {
8792 v.push((j + 1, j));
8793 }
8794 v
8795 })
8796 .collect();
8797 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8798 assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
8799 assert!(
8800 n_colors <= 3,
8801 "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
8802 );
8803 assert!(var_color.iter().all(|&c| c != u32::MAX));
8804 }
8805
8806 /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
8807 /// scattered through an otherwise diagonal `n`-variable problem.
8808 /// A plain coloring needs exactly `size` colors no matter how many
8809 /// blocks there are, because the blocks share no rows.
8810 fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
8811 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8812 let stride = n / blocks;
8813 for b in 0..blocks {
8814 let base = b * stride;
8815 for i in 0..size {
8816 for j in 0..=i {
8817 if i != j {
8818 pairs.push((base + i, base + j));
8819 }
8820 }
8821 }
8822 }
8823 pairs
8824 }
8825
8826 /// Many medium-degree columns must not be peeled.
8827 ///
8828 /// Regression: selecting candidates by "degree > 16x average" and then
8829 /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
8830 /// The columns that miss the cut stay in the conflict structure, so the
8831 /// base color count is untouched and the singleton colors are pure
8832 /// addition — these two patterns colored to 306 and 290 against a plain
8833 /// walk's 50 and 34.
8834 #[test]
8835 fn thousands_of_medium_degree_cols_are_not_peeled() {
8836 for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
8837 let pairs = disjoint_blocks(n, blocks, size);
8838 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8839 let n_peeled = peeled.iter().filter(|&&p| p).count();
8840 assert_eq!(
8841 n_peeled, 0,
8842 "degree-{size} columns do not pay for a singleton color \
8843 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
8844 );
8845 assert!(
8846 n_colors <= size + 1,
8847 "disjoint {size}x{size} blocks color by block size regardless \
8848 of block count; got {n_colors} for n={n}, blocks={blocks}"
8849 );
8850 }
8851 }
8852
8853 /// The pay-for-itself rule must not throw away the case peeling exists
8854 /// for: a handful of genuinely dense rows still get peeled, and still
8855 /// collapse the coloring.
8856 #[test]
8857 fn a_few_truly_dense_rows_are_still_peeled() {
8858 let n = 5_000;
8859 let dense_rows = 4;
8860 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8861 for d in 0..dense_rows {
8862 for j in 0..n {
8863 if j != d {
8864 pairs.push((j.max(d), j.min(d)));
8865 }
8866 }
8867 }
8868 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8869 let n_peeled = peeled.iter().filter(|&&p| p).count();
8870 assert_eq!(n_peeled, dense_rows, "every full row should peel");
8871 assert!(
8872 n_colors <= dense_rows + 2,
8873 "peeling {dense_rows} full rows should leave a diagonal remainder, \
8874 got {n_colors} colors"
8875 );
8876 }
8877
8878 /// The cap still binds, and when it does the kept columns are the
8879 /// highest-degree ones.
8880 #[test]
8881 fn peeling_is_capped_and_keeps_the_worst_offenders() {
8882 let n = 20_000;
8883 let dense_rows = 400;
8884 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8885 for d in 0..dense_rows {
8886 // Row d touches the first (n - d) columns, so degree strictly
8887 // decreases with d and the ordering is unambiguous.
8888 for j in 0..(n - d) {
8889 if j != d {
8890 pairs.push((j.max(d), j.min(d)));
8891 }
8892 }
8893 }
8894 let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8895 let n_peeled = peeled.iter().filter(|&&p| p).count();
8896 assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
8897 assert!(
8898 (0..MAX_PEELED_COLS).all(|d| peeled[d]),
8899 "the {MAX_PEELED_COLS} densest rows are the ones kept"
8900 );
8901 }
8902
8903 /// Header line 0 is `g<count> <opt0> ...`; the option words are the
8904 /// model's own and a solver echoes them into the `.sol` `Options`
8905 /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
8906 #[test]
8907 fn header_option_words_are_kept_verbatim() {
8908 let p = parse_nl_text(EQ_LIN).expect("parse");
8909 assert_eq!(p.ampl_options, vec![0, 1, 0]);
8910 }
8911
8912 /// A count that does not match the words present must not be
8913 /// guessed at — the writer falls back rather than emit a wrong block.
8914 #[test]
8915 fn a_truncated_option_list_is_dropped_not_padded() {
8916 let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
8917 let p = parse_nl_text(&text).expect("parse");
8918 assert!(
8919 p.ampl_options.is_empty(),
8920 "9 declared but 3 present: {:?}",
8921 p.ampl_options
8922 );
8923 }
8924
8925 #[test]
8926 fn constrained_tnlp_eval_g_jac_h() {
8927 let p = parse_nl_text(EQ_LIN).expect("parse");
8928 let mut t = NlTnlp::new(p);
8929 let info = t.get_nlp_info().unwrap();
8930 assert_eq!(info.m, 1);
8931 assert_eq!(info.nnz_jac_g, 2);
8932
8933 // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
8934 let mut g = [0.0_f64; 1];
8935 assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
8936 assert!((g[0] - 0.7).abs() < 1e-12);
8937
8938 // Jacobian structure: row 0, cols [0, 1].
8939 let mut irow = [0_i32; 2];
8940 let mut jcol = [0_i32; 2];
8941 assert!(t.eval_jac_g(
8942 None,
8943 true,
8944 SparsityRequest::Structure {
8945 irow: &mut irow,
8946 jcol: &mut jcol
8947 }
8948 ));
8949 assert_eq!(irow, [0, 0]);
8950 assert_eq!(jcol, [0, 1]);
8951
8952 // Jacobian values: both 1.0.
8953 let mut vals = [0.0_f64; 2];
8954 assert!(t.eval_jac_g(
8955 Some(&[0.3, 0.4]),
8956 true,
8957 SparsityRequest::Values { values: &mut vals }
8958 ));
8959 assert!((vals[0] - 1.0).abs() < 1e-12);
8960 assert!((vals[1] - 1.0).abs() < 1e-12);
8961
8962 // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
8963 // λ contributes nothing because the constraint is linear, and
8964 // x0^2 + x1^2 is separable so there's no (1,0) entry in the
8965 // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
8966 assert_eq!(info.nnz_h_lag, 2);
8967 let mut hirow = [0_i32; 2];
8968 let mut hjcol = [0_i32; 2];
8969 assert!(t.eval_h(
8970 None,
8971 true,
8972 1.0,
8973 None,
8974 true,
8975 SparsityRequest::Structure {
8976 irow: &mut hirow,
8977 jcol: &mut hjcol
8978 }
8979 ));
8980 assert_eq!(hirow, [0, 1]);
8981 assert_eq!(hjcol, [0, 1]);
8982 let mut hvals = [0.0_f64; 2];
8983 assert!(t.eval_h(
8984 Some(&[0.3, 0.4]),
8985 true,
8986 1.0,
8987 Some(&[0.5]),
8988 true,
8989 SparsityRequest::Values { values: &mut hvals }
8990 ));
8991 assert!((hvals[0] - 2.0).abs() < 1e-12);
8992 assert!((hvals[1] - 2.0).abs() < 1e-12);
8993 }
8994
8995 /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
8996 /// encoded as common-subexpression `V2`. Header line 10 declares
8997 /// one obj-only CSE; expression tree references `v2` twice.
8998 const CSE_OBJ: &str = "g3 0 1 0
89992 0 1 0 0
90000 1
90010 0
90020 2 0
90030 0 0 1
90040 0 0 0 0
90050 0
90060 0
90070 1 0 0 0
9008V2 0 0
9009o0
9010v0
9011v1
9012O0 0
9013o0
9014o5
9015v2
9016n2
9017v2
9018b
90193
90203
9021";
9022
9023 #[test]
9024 fn parses_v_segment_cse() {
9025 let p = parse_nl_text(CSE_OBJ).expect("parse");
9026 assert_eq!(p.n, 2);
9027 // f(1,2) = 9 + 3 = 12
9028 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
9029 assert!((f - 12.0).abs() < 1e-12, "got {f}");
9030 // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
9031 let mut g = [0.0_f64; 2];
9032 grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
9033 assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
9034 assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
9035 // collect_vars reaches into the CSE body and finds {0, 1}.
9036 let mut vs = BTreeSet::new();
9037 p.obj_nonlinear.collect_vars(&mut vs);
9038 assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
9039 }
9040
9041 /// `min (x0 - 1)^2` with three suffix segments attached: an
9042 /// integer constraint-suffix (target=1, kind=1), an integer var-
9043 /// suffix (target=0, kind=0), and a real var-suffix (target=0,
9044 /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
9045 /// `<idx> <value>` lines.
9046 const WITH_SUFFIXES: &str = "g3 0 1 0
90471 0 1 0 0
90480 1
90490 0
90500 1 0
90510 0 0 1
90520 0 0 0 0
90530 0
90540 0
90550 0 0 0 0
9056O0 0
9057o5
9058o1
9059v0
9060n1
9061n2
9062b
90633
9064S0 1 sens_state_1
90650 7
9066S4 1 sens_state_value_1
90670 4.5
9068";
9069
9070 #[test]
9071 fn parses_var_int_and_var_real_suffixes() {
9072 let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
9073 // Integer var-suffix: dense length 1, slot 0 = 7.
9074 let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
9075 assert_eq!(v.as_slice(), &[7]);
9076 // Real var-suffix: dense length 1, slot 0 = 4.5.
9077 let r = p
9078 .suffixes
9079 .var_real
9080 .get("sens_state_value_1")
9081 .expect("var_real");
9082 assert_eq!(r.len(), 1);
9083 assert!((r[0] - 4.5).abs() < 1e-12);
9084 // Other suffix slots stay empty.
9085 assert!(p.suffixes.con_int.is_empty());
9086 assert!(p.suffixes.con_real.is_empty());
9087 }
9088
9089 /// Two-variable + two-constraint problem with a constraint-level
9090 /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
9091 const WITH_CON_SUFFIX: &str = "g3 0 1 0
90922 2 1 0 0
90930 0
90940 0
90950 2 0
90960 0 0 1
90970 0 0 0 0
90984 0
90990 0
91000 0 0 0 0 0
9101C0
9102n0
9103C1
9104n0
9105O0 0
9106n0
9107r
91084 0.0
91094 0.0
9110b
91113
91123
9113k1
91140
9115J0 2
91160 1
91171 1
9118J1 2
91190 1
91201 -1
9121S1 2 sens_init_constr
91220 1
91231 2
9124";
9125
9126 #[test]
9127 fn parses_con_int_suffix() {
9128 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9129 let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
9130 // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
9131 assert_eq!(s.as_slice(), &[1, 2]);
9132 }
9133
9134 // ---- gh#785: a truncated file is rejected, not silently defaulted ----
9135 //
9136 // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
9137 // segment boundary is exactly the failure the issue reports: an
9138 // interrupted write. Each cut loses a different first segment and each
9139 // is caught by a different check, so all three are asserted — and the
9140 // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
9141 // what keeps these from passing against a parser that rejects
9142 // everything.
9143
9144 /// Everything from `at` (a segment header line) onward is gone.
9145 fn truncate_before(txt: &str, at: &str) -> String {
9146 let cut = txt
9147 .find(at)
9148 .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
9149 txt[..cut].to_string()
9150 }
9151
9152 #[test]
9153 fn truncation_before_the_row_bounds_is_a_parse_error() {
9154 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
9155 .expect_err("truncated file must not parse");
9156 assert!(
9157 err.contains("`r` (constraint-bounds) segment"),
9158 "error should name the missing segment: {err}"
9159 );
9160 }
9161
9162 #[test]
9163 fn truncation_before_the_variable_bounds_is_a_parse_error() {
9164 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
9165 .expect_err("truncated file must not parse");
9166 assert!(
9167 err.contains("`b` (variable-bounds) segment"),
9168 "error should name the missing segment: {err}"
9169 );
9170 }
9171
9172 /// The cut that leaves every bound in place and takes only the
9173 /// coefficients. Neither presence check can see it; the declared-vs-
9174 /// parsed nonzero count is the whole of the evidence.
9175 #[test]
9176 fn truncation_before_the_jacobian_is_a_parse_error() {
9177 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
9178 .expect_err("truncated file must not parse");
9179 assert!(
9180 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
9181 "error should report the mismatch: {err}"
9182 );
9183 }
9184
9185 /// The mismatch is an equality, not a floor: a file supplying *more*
9186 /// entries than it declares is as corrupt as one supplying fewer, and a
9187 /// `>=` check would wave it through.
9188 #[test]
9189 fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
9190 let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
9191 let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
9192 assert!(
9193 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
9194 "error should report the mismatch: {err}"
9195 );
9196 }
9197
9198 /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
9199 /// (n = m = 2) and hand back everything the engine would see.
9200 fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
9201 let mut obj = 1.0;
9202 let mut use_x = false;
9203 let mut x = vec![0.0; 2];
9204 let mut use_g = false;
9205 let mut g = vec![0.0; 2];
9206 let ok = tnlp.get_scaling_parameters(ScalingRequest {
9207 obj_scaling: &mut obj,
9208 use_x_scaling: &mut use_x,
9209 x_scaling: &mut x,
9210 use_g_scaling: &mut use_g,
9211 g_scaling: &mut g,
9212 });
9213 (ok, obj, use_x, x, use_g, g)
9214 }
9215
9216 /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
9217 /// the objective (`S6`) and one constraint (`S5`) reaches the
9218 /// engine's `user-scaling` pathway. The untagged second row is
9219 /// unscaled — its AMPL suffix default is 0, which is not a usable
9220 /// scale factor and reads as "not tagged".
9221 #[test]
9222 fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
9223 let nl = WITH_CON_SUFFIX.to_string()
9224 + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
9225 let p = parse_nl_text(&nl).expect("parse");
9226 let mut tnlp = NlTnlp::new(p);
9227 let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
9228 assert!(ok, "a tagged model must supply scaling");
9229 assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
9230 assert!(use_g);
9231 assert_eq!(g, vec![10.0, 1.0]);
9232 assert!(!use_x, "no variable suffix was declared");
9233 }
9234
9235 /// Variable-level `scaling_factor` entries are passed through, not
9236 /// dropped on the floor: `OrigIpoptNlp` does not model them and
9237 /// refuses the solve, which is the whole point of gh#483.
9238 #[test]
9239 fn scaling_factor_suffix_forwards_variable_factors() {
9240 let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
9241 let p = parse_nl_text(&nl).expect("parse");
9242 let mut tnlp = NlTnlp::new(p);
9243 let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
9244 assert!(ok);
9245 assert!(use_x, "variable factors must reach the engine");
9246 assert_eq!(x, vec![1.0, 3.0]);
9247 }
9248
9249 /// gh #703: with curvature-based scaling switched on, the computed
9250 /// factors are the base and a `scaling_factor` suffix the model
9251 /// actually carries wins **component by component** — an explicit
9252 /// factor from the modeller beats one inferred from the coefficients,
9253 /// and the components they did not tag keep the inferred one rather
9254 /// than snapping back to 1.
9255 #[test]
9256 fn a_user_suffix_overrides_the_computed_factor_component_wise() {
9257 let baseline = {
9258 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9259 let mut t = NlTnlp::new(p);
9260 assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
9261 scaling_of(&mut t).5
9262 };
9263 assert!(
9264 baseline.iter().all(|v| *v > 0.0),
9265 "curvature scaling should produce usable row factors, got {baseline:?}"
9266 );
9267
9268 // Tag row 0 only. AMPL's untagged default is 0, which must read as
9269 // "not tagged" and leave row 1 on the computed factor.
9270 let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
9271 let p = parse_nl_text(&nl).expect("parse");
9272 let mut t = NlTnlp::new(p);
9273 assert!(t.enable_curvature_scaling());
9274 let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
9275 assert!(ok);
9276 assert!(use_g && use_x);
9277 assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
9278 assert_eq!(
9279 g[1], baseline[1],
9280 "the untagged row keeps the computed one, not 1.0"
9281 );
9282 }
9283
9284 /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
9285 /// same answer the default `TNLP` impl gives, so `user-scaling`
9286 /// falls back to no scaling instead of a bogus all-zero vector.
9287 #[test]
9288 fn no_scaling_factor_suffix_declines() {
9289 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9290 let mut tnlp = NlTnlp::new(p);
9291 let (ok, ..) = scaling_of(&mut tnlp);
9292 assert!(!ok);
9293 }
9294
9295 #[test]
9296 fn rejects_suffix_with_out_of_range_index() {
9297 let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
9298 let err = parse_nl_text(&bad).expect_err("must reject");
9299 assert!(
9300 err.contains("out of range"),
9301 "expected out-of-range error, got: {err}"
9302 );
9303 }
9304
9305 #[test]
9306 fn tnlp_round_trip_solves() {
9307 let p = parse_nl_text(SIMPLE).expect("parse");
9308 let mut tnlp = NlTnlp::new(p);
9309 let info = tnlp.get_nlp_info().unwrap();
9310 assert_eq!(info.n, 2);
9311 assert_eq!(info.m, 0);
9312 let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
9313 assert!((f0 - 5.0).abs() < 1e-12);
9314 let mut g = [0.0_f64; 2];
9315 tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
9316 // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
9317 assert!((g[0] - (-2.0)).abs() < 1e-12);
9318 assert!((g[1] - (-4.0)).abs() < 1e-12);
9319 }
9320
9321 // ---- Sibling `.col` / `.row` name-file capture --------------------
9322 //
9323 // Names let diagnostics name the offending equation instead of "row 3"
9324 // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
9325 // the read path and the documented fallback-to-empty behavior.
9326
9327 use pounce_nlp::expression_provider::ExpressionProvider;
9328 use std::sync::atomic::{AtomicUsize, Ordering};
9329
9330 /// Unique scratch dir for one test (no `tempfile` dev-dep available).
9331 fn scratch_dir(tag: &str) -> std::path::PathBuf {
9332 static N: AtomicUsize = AtomicUsize::new(0);
9333 let seq = N.fetch_add(1, Ordering::Relaxed);
9334 let dir = std::env::temp_dir().join(format!(
9335 "pounce_nlnames_{}_{}_{}",
9336 std::process::id(),
9337 tag,
9338 seq
9339 ));
9340 std::fs::create_dir_all(&dir).expect("create scratch dir");
9341 dir
9342 }
9343
9344 #[test]
9345 fn read_name_file_reads_in_order() {
9346 let dir = scratch_dir("col_order");
9347 let p = dir.join("m.col");
9348 std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
9349 assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
9350 }
9351
9352 #[test]
9353 fn read_name_file_truncates_extra_lines() {
9354 // `.row` conventionally appends the objective name after the m
9355 // constraint names; `.take(expected)` must drop it so names stay
9356 // 1:1 with `g`.
9357 let dir = scratch_dir("row_obj");
9358 let p = dir.join("m.row");
9359 std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
9360 assert_eq!(
9361 read_name_file(&p, 2),
9362 vec!["mass_balance", "energy_balance"]
9363 );
9364 }
9365
9366 #[test]
9367 fn read_name_file_empty_on_short_or_missing() {
9368 let dir = scratch_dir("short");
9369 let short = dir.join("m.col");
9370 std::fs::write(&short, "only_one\n").unwrap();
9371 // Fewer lines than expected ⇒ empty (never a partial mapping).
9372 assert!(read_name_file(&short, 3).is_empty());
9373 // Missing file ⇒ empty, no error.
9374 assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
9375 }
9376
9377 #[test]
9378 fn read_nl_file_captures_sibling_names() {
9379 // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
9380 // names ride through onto the TNLP's ExpressionProvider.
9381 let dir = scratch_dir("sibling");
9382 let nl = dir.join("m.nl");
9383 std::fs::write(&nl, SIMPLE).unwrap();
9384 std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
9385
9386 let prob = read_nl_file(&nl).expect("parse + name capture");
9387 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9388 assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
9389
9390 let tnlp = NlTnlp::new(prob);
9391 assert_eq!(tnlp.variable_name(0), Some("alpha"));
9392 assert_eq!(tnlp.variable_name(1), Some("beta"));
9393 assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
9394 }
9395
9396 #[test]
9397 fn read_nl_file_without_names_yields_empty() {
9398 let dir = scratch_dir("noname");
9399 let nl = dir.join("m.nl");
9400 std::fs::write(&nl, SIMPLE).unwrap();
9401 let prob = read_nl_file(&nl).expect("parse");
9402 assert!(prob.var_names.is_empty());
9403 assert!(prob.con_names.is_empty());
9404 let tnlp = NlTnlp::new(prob);
9405 assert_eq!(tnlp.variable_name(0), None);
9406 }
9407
9408 #[test]
9409 fn read_nl_file_resolves_extensionless_ampl_stub() {
9410 // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
9411 // the `.nl` extension; the solver must read `mystub.nl`. Code
9412 // review 2026-06 item M15.
9413 let dir = scratch_dir("stub");
9414 std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
9415 // Pass the extensionless stub — the file `mystub` does not exist.
9416 let stub = dir.join("mystub");
9417 assert!(!stub.exists(), "stub must be extensionless / absent");
9418 let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
9419 assert_eq!(prob.n, 2);
9420 assert_eq!(prob.m, 0);
9421
9422 // Sibling name files are still found off the resolved stem.
9423 std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
9424 let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
9425 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9426 }
9427
9428 #[test]
9429 fn read_nl_file_prefers_exact_path_over_nl_sibling() {
9430 // An existing path is read verbatim — the `.nl` fallback only
9431 // kicks in when the literal path is missing, so a caller passing a
9432 // real file is never silently redirected to a `<file>.nl` sibling.
9433 let dir = scratch_dir("exact");
9434 // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
9435 std::fs::write(dir.join("data"), SIMPLE).unwrap();
9436 std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
9437 let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
9438 assert_eq!(prob.n, 2);
9439 }
9440
9441 #[test]
9442 fn append_extension_appends_rather_than_replaces() {
9443 use std::path::Path;
9444 assert_eq!(
9445 append_extension(Path::new("mystub"), "nl"),
9446 Path::new("mystub.nl")
9447 );
9448 // A stub that itself contains a dot keeps its stem (AMPL names it
9449 // `my.model.nl`, not `my.nl`).
9450 assert_eq!(
9451 append_extension(Path::new("my.model"), "nl"),
9452 Path::new("my.model.nl")
9453 );
9454 }
9455
9456 // ---- equation rendering (`print equation`) ----
9457
9458 fn names(v: &[&str]) -> Vec<String> {
9459 v.iter().map(|s| s.to_string()).collect()
9460 }
9461
9462 #[test]
9463 fn render_uses_variable_names_when_present() {
9464 let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9465 assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
9466 // Falls back to x[i] when names are absent.
9467 assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
9468 }
9469
9470 #[test]
9471 fn render_parenthesizes_by_precedence() {
9472 // (x0 + x1) * x2 must keep the parens around the sum.
9473 let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9474 let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
9475 assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
9476
9477 // x0 + x1 * x2 needs no parens (mul binds tighter).
9478 let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9479 let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
9480 assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
9481 }
9482
9483 #[test]
9484 fn render_subtraction_right_assoc_parens() {
9485 // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
9486 let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9487 let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
9488 assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
9489 }
9490
9491 #[test]
9492 fn render_functions_and_pow() {
9493 let sq = Expr::Binary(
9494 BinOp::Pow,
9495 Box::new(Expr::Var(0)),
9496 Box::new(Expr::Const(2.0)),
9497 );
9498 let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
9499 assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
9500 }
9501
9502 #[test]
9503 fn render_linear_signs_are_tidy() {
9504 // 1*a - 2*b + c (coef +1 omits the multiplier).
9505 let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
9506 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
9507 }
9508
9509 #[test]
9510 fn render_linear_skips_zero_coefficients() {
9511 // A 0 coefficient (a variable present only in the nonlinear part)
9512 // is dropped, not rendered as `0*x`.
9513 let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
9514 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
9515 // Leading term zero ⇒ the first emitted term still has no ` + `.
9516 let lin = vec![(0usize, 0.0), (1, 2.0)];
9517 assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
9518 }
9519
9520 #[test]
9521 fn render_sum_folds_negative_terms() {
9522 // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
9523 let sq = |i| {
9524 Expr::Binary(
9525 BinOp::Pow,
9526 Box::new(Expr::Var(i)),
9527 Box::new(Expr::Const(2.0)),
9528 )
9529 };
9530 let neg = |i| {
9531 Expr::Binary(
9532 BinOp::Mul,
9533 Box::new(Expr::Const(-1.0)),
9534 Box::new(Expr::Var(i)),
9535 )
9536 };
9537 let e = Expr::Sum(vec![
9538 sq(0),
9539 neg(1),
9540 Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
9541 ]);
9542 assert_eq!(
9543 render_expr(&e, &names(&["a", "b", "c"]), &[]),
9544 "a^2 - 1*b - c"
9545 );
9546 }
9547
9548 #[test]
9549 fn render_constraint_equation_forms() {
9550 // Build a 2-constraint problem by hand: an equality and a range.
9551 let mut prob = parse_nl_text(SIMPLE).unwrap();
9552 // Overwrite to a known small shape: 1 var, 2 cons.
9553 prob.n = 2;
9554 prob.m = 2;
9555 prob.var_names = names(&["mass_in", "mass_out"]);
9556 prob.con_names = names(&["balance", "window"]);
9557 prob.con_linear = vec![
9558 vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
9559 vec![(0, 1.0)], // mass_in
9560 ];
9561 prob.con_nonlinear = vec![
9562 NlBody::Tree(Expr::Const(0.0)),
9563 NlBody::Tree(Expr::Const(0.0)),
9564 ];
9565 prob.g_l = vec![0.0, 0.0];
9566 prob.g_u = vec![0.0, 500.0];
9567
9568 assert_eq!(
9569 render_constraint_equation(&prob, 0),
9570 "mass_in - mass_out = 0"
9571 );
9572 assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
9573
9574 let all = render_all_constraint_equations(&prob);
9575 assert_eq!(all.len(), 2);
9576 assert_eq!(all[1], "0 <= mass_in <= 500");
9577 }
9578
9579 #[test]
9580 fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
9581 let mut prob = parse_nl_text(SIMPLE).unwrap();
9582 prob.n = 3;
9583 prob.m = 2;
9584 // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
9585 // Row 1: linear in x2 only → support {2}.
9586 prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
9587 prob.con_nonlinear = vec![
9588 NlBody::Tree(Expr::Binary(
9589 BinOp::Mul,
9590 Box::new(Expr::Var(0)),
9591 Box::new(Expr::Var(2)),
9592 )),
9593 NlBody::Tree(Expr::Const(0.0)),
9594 ];
9595 prob.g_l = vec![0.0, 0.0];
9596 prob.g_u = vec![0.0, 0.0];
9597
9598 let (irow, jcol) = constraint_jacobian_sparsity(&prob);
9599 // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
9600 assert_eq!(irow, vec![0, 0, 0, 1]);
9601 assert_eq!(jcol, vec![0, 1, 2, 2]);
9602 }
9603
9604 #[test]
9605 fn funcall_string_arg_with_hash_is_not_truncated() {
9606 // Code review L31: an AMPL string argument is a Hollerith literal
9607 // `h<len>:<chars>` whose content is exactly <len> bytes and may
9608 // legitimately contain '#' (e.g. a parameters-directory path). The
9609 // old parser ran strip_comment() over the line first, truncating
9610 // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
9611 let mut p = Parser::new("h3:a#b\n", false);
9612 match p.parse_funcall_arg().expect("parse hollerith arg") {
9613 FuncallArg::Str(s) => assert_eq!(s, "a#b"),
9614 other => panic!("expected Str, got {other:?}"),
9615 }
9616 }
9617
9618 #[test]
9619 fn funcall_string_arg_honors_declared_length() {
9620 // The declared `<len>` is authoritative: exactly that many bytes
9621 // after the ':' form the string; trailing content (here a real
9622 // ` # comment`) is not part of it.
9623 let mut p = Parser::new("h3:abc # trailing comment\n", false);
9624 match p.parse_funcall_arg().expect("parse hollerith arg") {
9625 FuncallArg::Str(s) => assert_eq!(s, "abc"),
9626 other => panic!("expected Str, got {other:?}"),
9627 }
9628 }
9629
9630 // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
9631 //
9632 // AMPL emits these in place of the general `o5` (OPPOW) when one operand
9633 // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
9634 // negative-base-safe constant-power lowering applies. The eval points below
9635 // are chosen to pin down BOTH the arity and the operand order: a swapped
9636 // `base`/`exp` (or treating `o82` as a different unary op) gives a
9637 // different number at these points, so each assertion is discriminating.
9638
9639 /// Parse a single expression `expr_src` with `n` variables in scope,
9640 /// driving the real `parse_opcode` path through `parse_expr`.
9641 fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
9642 let mut p = Parser::new(expr_src, false);
9643 p.n = n;
9644 p.parse_expr().expect("parse expression")
9645 }
9646
9647 #[test]
9648 fn opcode_o82_square_is_unary_pow_of_two() {
9649 // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
9650 let e = parse_one_expr(1, "o82\nv0\n");
9651 match &e {
9652 Expr::Binary(BinOp::Pow, base, exp) => {
9653 assert!(matches!(**base, Expr::Var(0)));
9654 match **exp {
9655 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
9656 ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
9657 }
9658 }
9659 other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
9660 }
9661 // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
9662 // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
9663 assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
9664 assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
9665 // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
9666 let mut g = [0.0_f64; 1];
9667 grad_expr(&e, &[3.0], 1.0, &mut g);
9668 assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
9669 g[0] = 0.0;
9670 grad_expr(&e, &[-3.0], 1.0, &mut g);
9671 assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
9672 }
9673
9674 #[test]
9675 fn opcode_o81_const_exponent_is_base_pow_const() {
9676 // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
9677 let e = parse_one_expr(1, "o81\nv0\nn3\n");
9678 match &e {
9679 Expr::Binary(BinOp::Pow, base, exp) => {
9680 assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
9681 match **exp {
9682 Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
9683 ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
9684 }
9685 }
9686 other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
9687 }
9688 // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
9689 assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
9690 // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
9691 // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
9692 assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
9693 // gradient d/dx x^3 = 3x^2 = 12 at x=2.
9694 let mut g = [0.0_f64; 1];
9695 grad_expr(&e, &[2.0], 1.0, &mut g);
9696 assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
9697 }
9698
9699 #[test]
9700 fn opcode_o83_const_base_is_const_pow_exp() {
9701 // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
9702 let e = parse_one_expr(1, "o83\nn2\nv0\n");
9703 match &e {
9704 Expr::Binary(BinOp::Pow, base, exp) => {
9705 match **base {
9706 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
9707 ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
9708 }
9709 assert!(
9710 matches!(**exp, Expr::Var(0)),
9711 "exponent must be the variable"
9712 );
9713 }
9714 other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
9715 }
9716 // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
9717 assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
9718 assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
9719 // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
9720 let mut g = [0.0_f64; 1];
9721 grad_expr(&e, &[3.0], 1.0, &mut g);
9722 assert!(
9723 (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
9724 "grad at 3 = {} (want {})",
9725 g[0],
9726 8.0 * 2.0_f64.ln()
9727 );
9728 }
9729
9730 #[test]
9731 fn power_specializations_agree_with_general_o5() {
9732 // Where both are defined, o81/o82/o83 must be numerically identical to
9733 // the general `o5` pow on the same operands — they are only routing
9734 // hints, not different math.
9735 let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
9736 let o82 = parse_one_expr(1, "o82\nv0\n");
9737 let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
9738 let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
9739 let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
9740 let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
9741 for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
9742 assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
9743 assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
9744 // 2^x is real for all x; compare across the same points.
9745 assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
9746 }
9747 }
9748
9749 #[test]
9750 fn power_opcodes_round_trip_through_parse_nl_text() {
9751 // End-to-end through the public entry point: `min x0^2 + x1^2` written
9752 // with o82 (square) parses and evaluates like its o5 twin. Reuses the
9753 // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
9754 let nl = SIMPLE.replace(
9755 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9756 "o0\no82\nv0\no82\nv1\n",
9757 );
9758 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9759 let p = parse_nl_text(&nl).expect("parse o82 objective");
9760 // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
9761 assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
9762 assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
9763 }
9764
9765 #[test]
9766 fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
9767 // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
9768 // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
9769 // (the negative-base-safe path) rather than a generic `powf`. The check
9770 // at a NEGATIVE base is the one that would break if o81 wrongly routed
9771 // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
9772 let nl = SIMPLE.replace(
9773 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9774 "o0\no81\nv0\nn3\no81\nv1\nn3\n",
9775 );
9776 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9777 let p = parse_nl_text(&nl).expect("parse o81 objective");
9778 let mut tnlp = NlTnlp::new(p);
9779 tnlp.get_nlp_info().unwrap();
9780 // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
9781 let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
9782 assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
9783 // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
9784 let mut g = [0.0_f64; 2];
9785 assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
9786 assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
9787 assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
9788 }
9789
9790 // ---- Shared-CSE constraint tape (issue #476) ----------------------
9791
9792 /// Three constraints over one `V` segment (a `.nl` *defined variable*),
9793 /// `V3 = 2*x0 + 3*x1`, referenced by all three:
9794 /// C0: V3^2 C1: V3^3 + x2 C2: V3*x2
9795 /// `{BODY2}` is a substitution point so a variant can drop an opcode the
9796 /// hybrid path rejects into C2.
9797 const SHARED_CSE: &str = "g3 1 1 0
9798 3 3 1 0 0
9799 3 0
9800 0 0
9801 3 0 0
9802 0 0 0 1
9803 0 0 0 0 0
9804 8 3
9805 0 0
9806 0 1 0 0 0
9807V3 2 0
98080 2.0
98091 3.0
9810n0
9811C0
9812o5
9813v3
9814n2
9815C1
9816o0
9817o5
9818v3
9819n3
9820v2
9821C2
9822{BODY2}
9823O0 0
9824n0
9825r
98262 0
98272 0
98282 0
9829b
98303
98313
98323
9833k2
98343
98356
9836J0 2
98370 0
98381 0
9839J1 3
98400 0
98411 0
98422 0
9843J2 3
98440 0
98451 0
98462 0
9847G0 3
98480 1.0
98491 1.0
98502 1.0
9851";
9852
9853 fn shared_cse_nl(body2: &str) -> String {
9854 SHARED_CSE.replace("{BODY2}", body2)
9855 }
9856
9857 /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
9858 /// per-summand tapes return — it is a different traversal of the same
9859 /// DAG, not a different derivative. Forced on here regardless of
9860 /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
9861 /// the size heuristic that decides when to use it.
9862 #[test]
9863 fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
9864 let nl = shared_cse_nl("o2\nv3\nv2");
9865 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9866
9867 let mut hybrid = NlTnlp::new(p.clone());
9868 let info = hybrid.get_nlp_info().unwrap();
9869 let nnz = info.nnz_jac_g as usize;
9870 hybrid
9871 .con_hybrid
9872 .as_mut()
9873 .expect("CSE shared by 3 constraints must build the hybrid tape")
9874 .use_for_jac = true;
9875
9876 let mut flat = NlTnlp::new(p);
9877 flat.get_nlp_info().unwrap();
9878 flat.con_hybrid = None;
9879
9880 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9881 let mut jh = vec![0.0_f64; nnz];
9882 let mut jf = vec![0.0_f64; nnz];
9883 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9884 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9885 assert_eq!(
9886 jh, jf,
9887 "hybrid Jacobian differs from the flat tape at {x:?}"
9888 );
9889
9890 // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
9891 let s = 2.0 * x[0] + 3.0 * x[1];
9892 let want = [
9893 4.0 * s,
9894 6.0 * s,
9895 6.0 * s * s,
9896 9.0 * s * s,
9897 1.0,
9898 2.0 * x[2],
9899 3.0 * x[2],
9900 s,
9901 ];
9902 assert_eq!(nnz, want.len());
9903 for k in 0..nnz {
9904 assert!(
9905 (jh[k] - want[k]).abs() < 1e-9,
9906 "entry {k} at {x:?}: got {}, want {}",
9907 jh[k],
9908 want[k]
9909 );
9910 }
9911 }
9912 }
9913
9914 /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
9915 /// CSE `S = body(x0, x1)` (the caller passes the body's expression
9916 /// text, which must reference exactly `v0` and `v1`). A deep shared
9917 /// body over few local ops per row is the regime the op-ratio gates
9918 /// are meant to catch: no repository fixture has a CSE shared across
9919 /// constraints at all, so without this the gate-on paths have no
9920 /// natural coverage.
9921 fn shared_body_chain_nl(m: usize, body: &str) -> String {
9922 let n = m + 2;
9923 let nzc = 3 * m;
9924 let mut s = String::new();
9925 s.push_str("g3 1 1 0\n");
9926 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9927 s.push_str(&format!(" {m} 0\n 0 0\n"));
9928 s.push_str(&format!(" {n} 0 0\n"));
9929 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9930 s.push_str(&format!(" {nzc} {n}\n"));
9931 s.push_str(" 0 0\n 0 1 0 0 0\n");
9932 // The shared body.
9933 s.push_str(&format!("V{n} 0 0\n"));
9934 s.push_str(body);
9935 // Rows: S * x_{i+2}.
9936 for i in 0..m {
9937 s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
9938 }
9939 s.push_str("O0 0\nn0\n");
9940 s.push_str(&format!("x{n}\n"));
9941 for j in 0..n {
9942 s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
9943 }
9944 s.push_str("r\n");
9945 for _ in 0..m {
9946 s.push_str("2 0\n");
9947 }
9948 s.push_str("b\n");
9949 for _ in 0..n {
9950 s.push_str("3\n");
9951 }
9952 // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
9953 s.push_str(&format!("k{}\n", n - 1));
9954 let mut acc = 0;
9955 for j in 0..n - 1 {
9956 acc += if j < 2 { m } else { 1 };
9957 s.push_str(&format!("{acc}\n"));
9958 }
9959 for i in 0..m {
9960 s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
9961 }
9962 s.push_str(&format!("G0 {n}\n"));
9963 for j in 0..n {
9964 s.push_str(&format!("{j} 0.0\n"));
9965 }
9966 s
9967 }
9968
9969 /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
9970 /// The forward value saturates to `inf` past depth ≈ 5, which the
9971 /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
9972 /// bounded variant below instead, whose second-order terms would
9973 /// otherwise mix `inf`s of both signs into `NaN`.
9974 fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
9975 let mut body = String::new();
9976 for _ in 0..depth {
9977 body.push_str("o44\n");
9978 }
9979 body.push_str("o2\nn0.01\no0\nv0\nv1\n");
9980 shared_body_chain_nl(m, &body)
9981 }
9982
9983 /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
9984 /// (x0 + x1))` — mathematically the identity chain, so the value
9985 /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
9986 /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
9987 /// through both second-order sweeps. `2 * pairs` body ops drive the
9988 /// flat/shared op ratio as high as the Hessian gate tests need.
9989 fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
9990 let mut body = String::new();
9991 for _ in 0..pairs {
9992 body.push_str("o43\no44\n");
9993 }
9994 body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
9995 shared_body_chain_nl(m, &body)
9996 }
9997
9998 /// With a deep shared body the gate turns itself on, and the path it
9999 /// turns on must still agree with the flat tapes exactly.
10000 #[test]
10001 fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
10002 let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
10003
10004 let mut hybrid = NlTnlp::new(p.clone());
10005 let info = hybrid.get_nlp_info().unwrap();
10006 let nnz = info.nnz_jac_g as usize;
10007 assert!(
10008 hybrid
10009 .con_hybrid
10010 .as_ref()
10011 .expect("shared CSE must build the hybrid tape")
10012 .use_for_jac,
10013 "a 40-deep body shared by 16 rows is well past the op-ratio gate"
10014 );
10015
10016 let mut flat = NlTnlp::new(p);
10017 flat.get_nlp_info().unwrap();
10018 flat.con_hybrid = None;
10019
10020 for scale in [1.0_f64, -0.7, 2.5] {
10021 let x: Vec<f64> = (0..info.n as usize)
10022 .map(|j| scale * (0.2 + 0.03 * j as f64))
10023 .collect();
10024 let mut jh = vec![0.0_f64; nnz];
10025 let mut jf = vec![0.0_f64; nnz];
10026 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
10027 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
10028 assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
10029 assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
10030 }
10031 }
10032
10033 /// The Jacobian gate is off for a model whose shared bodies are small,
10034 /// because there the hybrid traversal's per-op overhead outweighs the
10035 /// halved forward sweep. `eval_g` still takes the hybrid path — that
10036 /// one is a win at any ratio.
10037 #[test]
10038 fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
10039 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10040 let mut t = NlTnlp::new(p);
10041 t.get_nlp_info().unwrap();
10042 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10043 assert!(
10044 !h.use_for_jac,
10045 "a 3-row model with a 2-term CSE is far below the op-ratio gate"
10046 );
10047 }
10048
10049 /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
10050 /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
10051 /// adjoints and running one prelude sweep reassociates floating-point
10052 /// products, so the two paths agree only to rounding on general inputs —
10053 /// but here every operation in both traversals is exact (dyadic values,
10054 /// small-integer coefficients, polynomial ops), so the results must be
10055 /// bit-identical, pinning the arithmetic itself and not just its
10056 /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
10057 /// path is covered independently of the size heuristic.
10058 #[test]
10059 fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
10060 let nl = shared_cse_nl("o2\nv3\nv2");
10061 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10062
10063 let mut hybrid = NlTnlp::new(p.clone());
10064 let info = hybrid.get_nlp_info().unwrap();
10065 let nnz = info.nnz_h_lag as usize;
10066 hybrid
10067 .con_hybrid
10068 .as_mut()
10069 .expect("CSE shared by 3 constraints must build the hybrid tape")
10070 .use_for_hess = true;
10071
10072 let mut flat = NlTnlp::new(p);
10073 flat.get_nlp_info().unwrap();
10074 flat.con_hybrid = None;
10075
10076 let pairs: Vec<(usize, usize)> = hybrid
10077 .h_irow
10078 .iter()
10079 .zip(&hybrid.h_jcol)
10080 .map(|(&i, &j)| (i as usize, j as usize))
10081 .collect();
10082
10083 // Two multiplier sets: one all-live, one with a dead row so the
10084 // λ == 0 skip is exercised on the hybrid path too.
10085 for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
10086 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10087 let mut hh = vec![0.0_f64; nnz];
10088 let mut hf = vec![0.0_f64; nnz];
10089 assert!(hybrid.eval_h(
10090 Some(&x),
10091 true,
10092 1.0,
10093 Some(&lam),
10094 true,
10095 SparsityRequest::Values { values: &mut hh }
10096 ));
10097 assert!(flat.eval_h(
10098 Some(&x),
10099 true,
10100 1.0,
10101 Some(&lam),
10102 true,
10103 SparsityRequest::Values { values: &mut hf }
10104 ));
10105 assert_eq!(
10106 hh, hf,
10107 "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
10108 );
10109
10110 // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
10111 // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
10112 // objective is constant, so the Lagrangian Hessian is
10113 // (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
10114 let s = 2.0 * x[0] + 3.0 * x[1];
10115 let q = 2.0 * lam[0] + 6.0 * s * lam[1];
10116 let dv = [2.0, 3.0, 0.0];
10117 for (k, &(i, j)) in pairs.iter().enumerate() {
10118 let mut want = q * dv[i] * dv[j];
10119 if i == 2 {
10120 want += lam[2] * dv[j];
10121 }
10122 if j == 2 {
10123 want += lam[2] * dv[i];
10124 }
10125 assert!(
10126 (hh[k] - want).abs() < 1e-9,
10127 "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
10128 hh[k]
10129 );
10130 }
10131 }
10132 }
10133 }
10134
10135 /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
10136 /// turns the Hessian gate on by itself, and the path it turns on must
10137 /// agree with the flat tapes. Not bitwise here: the shared prelude
10138 /// reverse sweep runs once over the `λ_k`-folded adjoints of all
10139 /// summands where the flat path runs per summand, and that
10140 /// reassociation moves transcendental results by rounding — so the bar
10141 /// is a relative few-ULP band, with the exact-arithmetic case pinned
10142 /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
10143 #[test]
10144 fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
10145 let m = 16;
10146 let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
10147
10148 let mut hybrid = NlTnlp::new(p.clone());
10149 let info = hybrid.get_nlp_info().unwrap();
10150 let nnz = info.nnz_h_lag as usize;
10151 assert!(
10152 hybrid
10153 .con_hybrid
10154 .as_ref()
10155 .expect("shared CSE must build the hybrid tape")
10156 .use_for_hess,
10157 "a 40-op body shared by 16 rows is well past the op-ratio gate"
10158 );
10159
10160 let mut flat = NlTnlp::new(p);
10161 flat.get_nlp_info().unwrap();
10162 flat.con_hybrid = None;
10163
10164 let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
10165 for scale in [1.0_f64, -0.7, 2.5] {
10166 let x: Vec<f64> = (0..info.n as usize)
10167 .map(|j| scale * (0.2 + 0.03 * j as f64))
10168 .collect();
10169 // Run the hybrid Jacobian first, the order a real solve
10170 // iteration uses. Its `gradient_summand` sweeps leave the
10171 // *Jacobian's* prelude adjoint arena dirty; the Hessian's
10172 // accumulators must be its own buffers with the all-zero-
10173 // between-colors invariant, or this seeds `eval_h` with a
10174 // stale row gradient (caught here).
10175 let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
10176 assert!(hybrid.eval_jac_g(
10177 Some(&x),
10178 true,
10179 SparsityRequest::Values { values: &mut jac }
10180 ));
10181 let mut hh = vec![0.0_f64; nnz];
10182 let mut hf = vec![0.0_f64; nnz];
10183 assert!(hybrid.eval_h(
10184 Some(&x),
10185 true,
10186 1.0,
10187 Some(&lam),
10188 true,
10189 SparsityRequest::Values { values: &mut hh }
10190 ));
10191 assert!(flat.eval_h(
10192 Some(&x),
10193 true,
10194 1.0,
10195 Some(&lam),
10196 true,
10197 SparsityRequest::Values { values: &mut hf }
10198 ));
10199 for k in 0..nnz {
10200 assert!(
10201 hh[k].is_finite() && hf[k].is_finite(),
10202 "non-finite Hessian entry {k} defeats the comparison"
10203 );
10204 let tol = 1e-12 * hf[k].abs().max(1.0);
10205 assert!(
10206 (hh[k] - hf[k]).abs() <= tol,
10207 "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
10208 hh[k],
10209 hf[k]
10210 );
10211 }
10212 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10213 }
10214 }
10215
10216 /// `.nl` text for two independent shared-CSE blocks of different widths:
10217 /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
10218 /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
10219 /// of the form `body * x_r`.
10220 ///
10221 /// The width difference is the point. A body summing `w` variables gives
10222 /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
10223 /// conflict and the coloring must spend `w` colors on them; the narrower
10224 /// block reuses the low colors. The surplus colors therefore belong to
10225 /// block A alone, and a per-color prelude walk over *both* bodies would
10226 /// be doing work for a body that color cannot reach.
10227 fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
10228 let nvars = wide + narrow;
10229 let m = 2 * rows;
10230 let n = nvars + m;
10231 // Block A rows touch `wide` body vars + 1 row var; block B rows
10232 // touch `narrow` + 1.
10233 let nzc = rows * (wide + 1) + rows * (narrow + 1);
10234 let mut s = String::new();
10235 s.push_str("g3 1 1 0\n");
10236 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
10237 s.push_str(&format!(" {m} 0\n 0 0\n"));
10238 s.push_str(&format!(" {n} 0 0\n"));
10239 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
10240 s.push_str(&format!(" {nzc} {n}\n"));
10241 s.push_str(" 0 0\n 0 2 0 0 0\n");
10242 // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
10243 // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
10244 for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
10245 s.push_str(&format!("V{} 0 0\n", n + b));
10246 s.push_str("o41\n");
10247 for _ in 0..count - 1 {
10248 s.push_str("o0\n");
10249 }
10250 for j in 0..*count {
10251 s.push_str(&format!("v{}\n", base + j));
10252 }
10253 }
10254 // Rows: body_b * x_{rowvar}.
10255 for i in 0..m {
10256 let b = i / rows;
10257 s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
10258 }
10259 s.push_str("O0 0\nn0\n");
10260 s.push_str(&format!("x{n}\n"));
10261 for j in 0..n {
10262 s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
10263 }
10264 s.push_str("r\n");
10265 for _ in 0..m {
10266 s.push_str("2 0\n");
10267 }
10268 s.push_str("b\n");
10269 for _ in 0..n {
10270 s.push_str("3\n");
10271 }
10272 // Cumulative Jacobian column counts for the first n-1 columns.
10273 s.push_str(&format!("k{}\n", n - 1));
10274 let mut acc = 0;
10275 for j in 0..n - 1 {
10276 acc += if j < nvars { rows } else { 1 };
10277 s.push_str(&format!("{acc}\n"));
10278 }
10279 for i in 0..m {
10280 let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
10281 s.push_str(&format!("J{i} {}\n", count + 1));
10282 let mut cols: Vec<usize> = (base..base + count).collect();
10283 cols.push(nvars + i);
10284 cols.sort_unstable();
10285 for c in cols {
10286 s.push_str(&format!("{c} 0.0\n"));
10287 }
10288 }
10289 s.push_str(&format!("G0 {n}\n"));
10290 for j in 0..n {
10291 s.push_str(&format!("{j} 0.0\n"));
10292 }
10293 s
10294 }
10295
10296 /// Both prelude sweeps run once per color, so iterating the whole prelude
10297 /// each time would cost `n_colors × |prelude|` where the op-ratio gate
10298 /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
10299 /// `eval_h` instead walks the union of that color's summands'
10300 /// `prelude_reach`.
10301 ///
10302 /// What this can and cannot pin is worth being exact about, because the
10303 /// change is pure performance: walking the whole prelude per color
10304 /// computes the *same* Hessian, so no assertion on output values can
10305 /// detect it, and a timing assertion would be flaky. So this asserts the
10306 /// two things that are checkable — that the table the sweeps iterate is
10307 /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
10308 /// where colors genuinely reach different bodies, and that each reach list
10309 /// is ascending and operand-closed, the invariants that make the narrowed
10310 /// walk safe — plus agreement with the flat tapes under narrowing.
10311 #[test]
10312 fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
10313 let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
10314 let mut hybrid = NlTnlp::new(p.clone());
10315 let info = hybrid.get_nlp_info().unwrap();
10316 let nnz = info.nnz_h_lag as usize;
10317 let m = info.m as usize;
10318
10319 {
10320 let h = hybrid
10321 .con_hybrid
10322 .as_mut()
10323 .expect("two shared CSE bodies must build the hybrid tape");
10324 h.use_for_hess = true;
10325
10326 let np = h.tape.n_prelude_ops();
10327 let n_colors = h.hess_color_reach_off.len() - 1;
10328 let total: usize = h.hess_color_reach.len();
10329 assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
10330 assert!(
10331 total < n_colors * np,
10332 "per-color reach must be strictly smaller than walking the whole \
10333 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
10334 n_colors * np
10335 );
10336 // Every reach list must be ascending and operand-closed, which is
10337 // what makes the narrowed walk safe.
10338 for c in 0..n_colors {
10339 let r =
10340 &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
10341 assert!(
10342 r.windows(2).all(|w| w[0] < w[1]),
10343 "color {c} reach is not strictly ascending"
10344 );
10345 let member: std::collections::HashSet<u32> = r.iter().copied().collect();
10346 for &i in r {
10347 let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
10348 for opnd in [a, b].into_iter().flatten() {
10349 assert!(
10350 member.contains(&(opnd as u32)),
10351 "color {c}: slot {i}'s operand {opnd} is missing from its reach"
10352 );
10353 }
10354 }
10355 }
10356 }
10357
10358 let mut flat = NlTnlp::new(p);
10359 flat.get_nlp_info().unwrap();
10360 flat.con_hybrid = None;
10361
10362 let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
10363 for scale in [1.0_f64, -0.6] {
10364 let x: Vec<f64> = (0..info.n as usize)
10365 .map(|j| scale * (0.15 + 0.02 * j as f64))
10366 .collect();
10367 let mut hh = vec![0.0_f64; nnz];
10368 let mut hf = vec![0.0_f64; nnz];
10369 assert!(hybrid.eval_h(
10370 Some(&x),
10371 true,
10372 1.0,
10373 Some(&lam),
10374 true,
10375 SparsityRequest::Values { values: &mut hh }
10376 ));
10377 assert!(flat.eval_h(
10378 Some(&x),
10379 true,
10380 1.0,
10381 Some(&lam),
10382 true,
10383 SparsityRequest::Values { values: &mut hf }
10384 ));
10385 for k in 0..nnz {
10386 let tol = 1e-12 * hf[k].abs().max(1.0);
10387 assert!(
10388 (hh[k] - hf[k]).abs() <= tol,
10389 "narrowed-reach Hessian entry {k} at scale {scale}: \
10390 hybrid {} vs flat {}",
10391 hh[k],
10392 hf[k]
10393 );
10394 }
10395 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10396 }
10397 }
10398
10399 /// The Hessian gate is off for a model whose shared bodies are small —
10400 /// below the ratio where the shared prelude sweeps pay for the hybrid
10401 /// traversal's per-op overhead — leaving `eval_h` on the flat path
10402 /// (which the gate keeps bit-identical for such models by definition).
10403 #[test]
10404 fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
10405 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10406 let mut t = NlTnlp::new(p);
10407 t.get_nlp_info().unwrap();
10408 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10409 assert!(
10410 !h.use_for_hess,
10411 "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
10412 );
10413 }
10414
10415 /// A CSE referenced from several constraints is evaluated once per
10416 /// `eval_g` via the shared prelude instead of once per reference. The
10417 /// values must be bit-identical to the flat per-summand `Tape` path,
10418 /// which is what makes the optimization safe to apply unconditionally.
10419 #[test]
10420 fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
10421 let nl = shared_cse_nl("o2\nv3\nv2");
10422 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10423 let mut hybrid = NlTnlp::new(p.clone());
10424 hybrid.get_nlp_info().unwrap();
10425 let h = hybrid
10426 .con_hybrid
10427 .as_ref()
10428 .expect("CSE shared by 3 constraints must take the hybrid path");
10429 assert!(
10430 h.tape.n_prelude_ops() > 0,
10431 "shared CSE body must land in the prelude"
10432 );
10433
10434 // Same model with the hybrid path switched off: the reference.
10435 let mut flat = NlTnlp::new(p);
10436 flat.get_nlp_info().unwrap();
10437 flat.con_hybrid = None;
10438
10439 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10440 let mut gh = [0.0_f64; 3];
10441 let mut gf = [0.0_f64; 3];
10442 assert!(hybrid.eval_g(&x, true, &mut gh));
10443 assert!(flat.eval_g(&x, true, &mut gf));
10444 // V3 = 2 x0 + 3 x1.
10445 let s = 2.0 * x[0] + 3.0 * x[1];
10446 let want = [s * s, s * s * s + x[2], s * x[2]];
10447 for i in 0..3 {
10448 assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
10449 assert!(
10450 (gh[i] - want[i]).abs() < 1e-9,
10451 "row {i}: got {}, want {}",
10452 gh[i],
10453 want[i]
10454 );
10455 }
10456 }
10457 }
10458
10459 /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
10460 /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
10461 /// take that path after `hybrid_supported` clears the model. Here a
10462 /// min-list in one constraint has to disable it for the whole block —
10463 /// falling back, not panicking.
10464 #[test]
10465 fn unsupported_opcode_falls_back_to_the_flat_tape() {
10466 let nl = shared_cse_nl("o11\n2\nv3\nv2");
10467 let p = parse_nl_text(&nl).expect("parse min-list model");
10468 let mut tnlp = NlTnlp::new(p);
10469 tnlp.get_nlp_info().unwrap();
10470 assert!(
10471 tnlp.con_hybrid.is_none(),
10472 "a min-list anywhere in the constraint block must disable the hybrid path"
10473 );
10474 let mut g = [0.0_f64; 3];
10475 assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
10476 let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
10477 assert!((g[0] - s * s).abs() < 1e-9);
10478 assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
10479 }
10480
10481 // ---- In-memory construction + HVP (issue #469) --------------------
10482
10483 fn v(i: usize) -> Expr {
10484 Expr::Var(i)
10485 }
10486
10487 fn c(x: Number) -> Expr {
10488 Expr::Const(x)
10489 }
10490
10491 fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
10492 Expr::Binary(op, Box::new(a), Box::new(b))
10493 }
10494
10495 fn un(op: UnaryOp, a: Expr) -> Expr {
10496 Expr::Unary(op, Box::new(a))
10497 }
10498
10499 /// `NlProblemParts` for an `n`-variable, unbounded model.
10500 fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
10501 let m = constraints.len();
10502 NlProblemParts {
10503 minimize: true,
10504 objective,
10505 obj_constant: 0.0,
10506 constraints,
10507 x_l: vec![-1e19; n],
10508 x_u: vec![1e19; n],
10509 x0: vec![0.0; n],
10510 g_l: vec![-1e19; m],
10511 g_u: vec![1e19; m],
10512 var_names: Vec::new(),
10513 con_names: Vec::new(),
10514 }
10515 }
10516
10517 /// A model built from expressions evaluates exactly like a parsed one:
10518 /// objective, gradient, constraints, and Jacobian all come from the
10519 /// same tape, with no `.nl` text in the loop.
10520 #[test]
10521 fn from_expressions_builds_evaluable_problem() {
10522 // min (1-x0)^2 + 100*(x1 - x0^2)^2 s.t. x0^2 + x1^2 <= 2
10523 let rosen = bin(
10524 BinOp::Add,
10525 bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
10526 bin(
10527 BinOp::Mul,
10528 c(100.0),
10529 bin(
10530 BinOp::Pow,
10531 bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
10532 c(2.0),
10533 ),
10534 ),
10535 );
10536 let circle = bin(
10537 BinOp::Add,
10538 bin(BinOp::Pow, v(0), c(2.0)),
10539 bin(BinOp::Pow, v(1), c(2.0)),
10540 );
10541
10542 let mut p = parts(2, rosen, vec![circle]);
10543 p.g_l = vec![0.0];
10544 p.g_u = vec![2.0];
10545 p.x0 = vec![-1.2, 1.0];
10546 p.var_names = names(&["x", "y"]);
10547 p.con_names = names(&["circle"]);
10548
10549 let prob = NlProblem::from_expressions(p).expect("build");
10550 assert_eq!((prob.n, prob.m), (2, 1));
10551 assert_eq!(prob.var_names, names(&["x", "y"]));
10552
10553 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10554 t.get_nlp_info().unwrap();
10555
10556 // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
10557 let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
10558 assert!((f - 24.2).abs() < 1e-10, "f = {f}");
10559
10560 // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
10561 // = (4.4 + 480*(-0.44)... ) — computed below rather than
10562 // transcribed, so the check is the formula, not an editor.
10563 let (x0, x1) = (-1.2, 1.0);
10564 let want = [
10565 -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
10566 200.0 * (x1 - x0 * x0),
10567 ];
10568 let mut g = [0.0_f64; 2];
10569 assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
10570 for j in 0..2 {
10571 assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
10572 }
10573
10574 // g(x) = x0^2 + x1^2 = 2.44
10575 let mut gv = [0.0_f64; 1];
10576 assert!(t.eval_g(&[x0, x1], true, &mut gv));
10577 assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
10578 }
10579
10580 /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
10581 /// this path. None of the three survives a `.nl` round trip in a
10582 /// typical frontend — `atan2` has no two-argument funcall path,
10583 /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
10584 /// at all — which is the reason the in-memory door exists.
10585 #[test]
10586 fn from_expressions_carries_ops_nl_cannot_express() {
10587 let obj = Expr::Sum(vec![
10588 bin(BinOp::Atan2, v(0), v(1)),
10589 Expr::MinList(vec![v(0), v(1)]),
10590 Expr::MaxList(vec![v(0), v(1)]),
10591 un(UnaryOp::Erf, v(0)),
10592 ]);
10593 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10594 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10595 t.get_nlp_info().unwrap();
10596
10597 let x: [Number; 2] = [0.8, 1.5];
10598 // atan2 + min + max + erf; min+max == x0+x1 for any pair.
10599 let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
10600 let f = t.eval_f(&x, true).unwrap();
10601 assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
10602 }
10603
10604 /// A `Var` index past `n` would be an out-of-bounds read in the
10605 /// tape's forward sweep. It has to be caught at construction, while
10606 /// it is still a diagnosable user error.
10607 #[test]
10608 fn from_expressions_rejects_out_of_range_var() {
10609 let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
10610 .expect_err("Var(5) with n = 2 must be rejected");
10611 assert!(err.contains("Var(5)"), "{err}");
10612
10613 let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
10614 .expect_err("constraint Var(2) with n = 2 must be rejected");
10615 assert!(err.contains("constraint 0"), "{err}");
10616
10617 // Length mismatches are errors too, not panics.
10618 let mut p = parts(2, c(0.0), Vec::new());
10619 p.x0 = vec![0.0; 3];
10620 let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
10621 assert!(err.contains("x0"), "{err}");
10622 }
10623
10624 /// An out-of-range `Var` must be caught wherever it hides, not just at
10625 /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
10626 /// branch, and a funcall argument all reach the same forward sweep.
10627 #[test]
10628 fn from_expressions_finds_out_of_range_vars_in_every_position() {
10629 let inner_cse = Arc::new(v(7));
10630 let cases: Vec<(&str, Expr)> = vec![
10631 ("bare", v(7)),
10632 ("cse", Expr::Cse(Arc::new(v(7)))),
10633 ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
10634 (
10635 "cond branch",
10636 Expr::Cond {
10637 cond: Box::new(c(1.0)),
10638 then_: Box::new(v(7)),
10639 else_: Box::new(c(0.0)),
10640 },
10641 ),
10642 ("min list", Expr::MinList(vec![c(0.0), v(7)])),
10643 (
10644 "sum",
10645 Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
10646 ),
10647 ];
10648 for (label, e) in cases {
10649 let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
10650 .err()
10651 .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
10652 assert!(err.contains("Var(7)"), "{label}: {err}");
10653 }
10654 }
10655
10656 /// A balanced share-DAG: each level is one `Cse` whose body
10657 /// references the level below twice. Depth `d` is `d` distinct nodes
10658 /// but `2^d` paths, so any walk that re-enters a shared body per
10659 /// occurrence is Θ(2^d).
10660 fn share_dag(depth: usize) -> Expr {
10661 let mut e = v(0);
10662 for _ in 0..depth {
10663 let shared = Arc::new(e);
10664 e = bin(
10665 BinOp::Add,
10666 Expr::Cse(Arc::clone(&shared)),
10667 Expr::Cse(shared),
10668 );
10669 }
10670 e
10671 }
10672
10673 /// The walks over a shared DAG must be memoized, not exponential.
10674 ///
10675 /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
10676 /// does not "fail" so much as never finish, which is exactly the
10677 /// signal. `from_expressions` is the door that makes such a DAG
10678 /// trivially constructible, but the blowup was reachable through
10679 /// `collect_vars` (which presolve calls on every solve, via
10680 /// `get_variables_linearity`) and `collect_funcall_ids` (which
10681 /// `NlTnlp::try_new` runs over every row).
10682 #[test]
10683 fn shared_dag_walks_are_memoized_not_exponential() {
10684 const DEPTH: usize = 30;
10685 let e = share_dag(DEPTH);
10686
10687 let mut vars = BTreeSet::new();
10688 collect_vars(&e, &mut vars);
10689 assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
10690
10691 let mut ids = BTreeSet::new();
10692 super::super::nl_external::collect_funcall_ids(&e, &mut ids);
10693 assert!(ids.is_empty());
10694
10695 // The whole build path, end to end: validation, tape construction,
10696 // and the linearity metadata presolve consumes.
10697 let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
10698 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10699 t.get_nlp_info().unwrap();
10700 let mut lin = vec![Linearity::Linear; 1];
10701 assert!(t.get_variables_linearity(&mut lin));
10702 }
10703
10704 /// `from_expressions` cannot carry AMPL imported functions — there is
10705 /// nowhere to put the `F`-segment declarations that bind a funcall id
10706 /// to a library — so a `Funcall` must be refused up front. Accepting it
10707 /// produces "AMPLFUNC is not set", which the user cannot act on:
10708 /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
10709 #[test]
10710 fn from_expressions_rejects_imported_function_calls() {
10711 let call = Expr::Funcall {
10712 id: 0,
10713 args: vec![FuncallArg::Real(v(0))],
10714 };
10715 let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
10716 .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
10717 assert!(err.contains("imported function"), "{err}");
10718 assert!(
10719 err.contains("read_nl") || err.contains("parse_nl_text"),
10720 "the error must point at the paths that do support externals: {err}"
10721 );
10722
10723 // Also when buried in a constraint, behind a Cse.
10724 let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
10725 let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
10726 .expect_err("a buried Funcall must be rejected too");
10727 assert!(err.contains("constraint 0"), "{err}");
10728 }
10729
10730 /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
10731 /// same tapes, same weights, one seed instead of a color sweep. The
10732 /// objective and both constraints are chosen with cross terms so the
10733 /// off-diagonal blocks actually carry signal.
10734 #[test]
10735 fn hessian_vector_product_matches_dense_hessian() {
10736 let obj = Expr::Sum(vec![
10737 bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
10738 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10739 un(UnaryOp::Erf, v(2)),
10740 ]);
10741 let cons = vec![
10742 bin(
10743 BinOp::Add,
10744 bin(BinOp::Pow, v(0), c(2.0)),
10745 un(UnaryOp::Sin, v(2)),
10746 ),
10747 bin(BinOp::Mul, v(1), v(2)),
10748 ];
10749 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10750 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10751 let info = t.get_nlp_info().unwrap();
10752
10753 let x = [0.3, -0.7, 1.1];
10754 let lam = [0.5, -1.25];
10755 let obj_factor = 2.0;
10756
10757 // Dense Hessian from the sparse lower triangle.
10758 let nnz = info.nnz_h_lag as usize;
10759 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10760 assert!(t.eval_h(
10761 None,
10762 false,
10763 1.0,
10764 None,
10765 false,
10766 SparsityRequest::Structure {
10767 irow: &mut irow,
10768 jcol: &mut jcol
10769 }
10770 ));
10771 let mut hvals = vec![0.0_f64; nnz];
10772 assert!(t.eval_h(
10773 Some(&x),
10774 true,
10775 obj_factor,
10776 Some(&lam),
10777 true,
10778 SparsityRequest::Values { values: &mut hvals }
10779 ));
10780 let mut dense = [[0.0_f64; 3]; 3];
10781 for k in 0..nnz {
10782 let (i, j) = (irow[k] as usize, jcol[k] as usize);
10783 dense[i][j] += hvals[k];
10784 if i != j {
10785 dense[j][i] += hvals[k];
10786 }
10787 }
10788
10789 // Each unit seed recovers a column; a mixed seed catches an HVP
10790 // that only happens to be right on the basis vectors.
10791 let seeds: [[Number; 3]; 4] = [
10792 [1.0, 0.0, 0.0],
10793 [0.0, 1.0, 0.0],
10794 [0.0, 0.0, 1.0],
10795 [0.4, -1.3, 2.0],
10796 ];
10797 let mut out = vec![0.0; 3];
10798 for s in &seeds {
10799 t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
10800 .expect("hvp");
10801 for i in 0..3 {
10802 let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
10803 assert!(
10804 (out[i] - want).abs() < 1e-9,
10805 "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
10806 out[i]
10807 );
10808 }
10809 }
10810 }
10811
10812 /// `lam = None` is the objective block alone, and `out` is
10813 /// overwritten (not accumulated) so a reused buffer is safe.
10814 #[test]
10815 fn hessian_vector_product_defaults_and_validation() {
10816 // f = x0^2 + 3 x0 x1 -> ∇²f = [[2, 3], [3, 0]]
10817 let obj = bin(
10818 BinOp::Add,
10819 bin(BinOp::Pow, v(0), c(2.0)),
10820 bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
10821 );
10822 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10823 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10824 t.get_nlp_info().unwrap();
10825
10826 let mut out = vec![7.0, -7.0]; // dirty buffer
10827 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
10828 .expect("hvp");
10829 assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
10830 assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
10831
10832 // obj_factor scales linearly.
10833 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
10834 .expect("hvp");
10835 assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
10836
10837 // Length mismatches are errors, not panics or silent truncation.
10838 let mut short = vec![0.0; 1];
10839 assert!(
10840 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
10841 .is_err()
10842 );
10843 assert!(
10844 t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
10845 .is_err()
10846 );
10847 assert!(
10848 t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
10849 .is_err()
10850 );
10851 }
10852
10853 /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
10854 /// Hessian — the sparse shape an IPM actually meets. The block HVP has
10855 /// to reproduce it column for column, including the structural zeros:
10856 /// a bug that leaked coupling between non-adjacent variables would
10857 /// show up here and nowhere in a small dense test.
10858 #[test]
10859 fn hessian_vector_products_on_a_sparse_hessian() {
10860 const N: usize = 8;
10861 let mut terms = Vec::new();
10862 for i in 0..N - 1 {
10863 terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
10864 }
10865 for i in 0..N {
10866 terms.push(un(UnaryOp::Exp, v(i)));
10867 }
10868 let prob =
10869 NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
10870 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10871 let info = t.get_nlp_info().unwrap();
10872
10873 // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
10874 assert_eq!(
10875 info.nnz_h_lag as usize,
10876 2 * N - 1,
10877 "chain objective should give a tridiagonal Hessian, not a dense one"
10878 );
10879
10880 let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
10881
10882 // Densify the sparse triangle for the reference.
10883 let nnz = info.nnz_h_lag as usize;
10884 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10885 assert!(t.eval_h(
10886 None,
10887 false,
10888 1.0,
10889 None,
10890 false,
10891 SparsityRequest::Structure {
10892 irow: &mut irow,
10893 jcol: &mut jcol
10894 }
10895 ));
10896 let mut hvals = vec![0.0; nnz];
10897 assert!(t.eval_h(
10898 Some(&x),
10899 true,
10900 1.0,
10901 None,
10902 true,
10903 SparsityRequest::Values { values: &mut hvals }
10904 ));
10905 let mut dense = vec![vec![0.0; N]; N];
10906 for k in 0..nnz {
10907 let (i, j) = (irow[k] as usize, jcol[k] as usize);
10908 dense[i][j] += hvals[k];
10909 if i != j {
10910 dense[j][i] += hvals[k];
10911 }
10912 }
10913
10914 // One block of N unit seeds recovers the whole matrix — the
10915 // "densify via HVPs" path, in one call.
10916 let mut seeds = vec![0.0; N * N];
10917 for cc in 0..N {
10918 seeds[cc * N + cc] = 1.0;
10919 }
10920 let mut out = vec![0.0; N * N];
10921 t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
10922 .expect("block hvp");
10923 for cc in 0..N {
10924 for i in 0..N {
10925 assert!(
10926 (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
10927 "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
10928 out[cc * N + i],
10929 dense[i][cc]
10930 );
10931 }
10932 }
10933 }
10934
10935 /// The block form must agree with `k` separate single-vector calls
10936 /// (it shares one forward sweep across directions, so a bug there
10937 /// would show as a per-direction discrepancy), and must skip an
10938 /// all-zero direction without disturbing its neighbours.
10939 #[test]
10940 fn hessian_vector_products_match_repeated_single_calls() {
10941 let obj = Expr::Sum(vec![
10942 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10943 bin(BinOp::Pow, v(2), c(4.0)),
10944 bin(BinOp::Mul, v(0), v(2)),
10945 ]);
10946 let cons = vec![bin(BinOp::Mul, v(1), v(2))];
10947 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10948 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10949 t.get_nlp_info().unwrap();
10950
10951 let x = [0.4, -0.6, 1.3];
10952 let lam = [0.75];
10953 let cols: [[Number; 3]; 4] = [
10954 [1.0, 2.0, -3.0],
10955 [0.0, 0.0, 0.0], // the skipped direction
10956 [0.5, 0.0, 0.0],
10957 [-1.0, 1.0, 1.0],
10958 ];
10959
10960 let mut block = vec![0.0; 3 * cols.len()];
10961 let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
10962 t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
10963 .expect("block hvp");
10964
10965 for (c, col) in cols.iter().enumerate() {
10966 let mut single = vec![0.0; 3];
10967 t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
10968 .expect("single hvp");
10969 for i in 0..3 {
10970 assert!(
10971 (block[c * 3 + i] - single[i]).abs() < 1e-12,
10972 "direction {c} row {i}: block={:.12e} single={:.12e}",
10973 block[c * 3 + i],
10974 single[i]
10975 );
10976 }
10977 }
10978 // The zero direction really is zero, not stale scratch.
10979 assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
10980 }
10981
10982 /// `k = 0` is a legal empty block, and the length checks scale with
10983 /// `k` rather than assuming a single direction.
10984 #[test]
10985 fn hessian_vector_products_validate_block_shape() {
10986 let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
10987 .expect("build");
10988 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10989 t.get_nlp_info().unwrap();
10990
10991 let mut empty: Vec<Number> = Vec::new();
10992 assert!(
10993 t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
10994 .is_ok()
10995 );
10996
10997 // v sized for one direction while k says two.
10998 let mut out = vec![0.0; 4];
10999 assert!(
11000 t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
11001 .is_err()
11002 );
11003 // out sized for one direction while k says two.
11004 let mut short = vec![0.0; 2];
11005 assert!(
11006 t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
11007 .is_err()
11008 );
11009 }
11010
11011 /// A `maximize` model's objective is negated by the evaluator, and
11012 /// the HVP has to agree with `eval_h` about that — otherwise a
11013 /// Hessian-free step would climb where the sparse path descends.
11014 #[test]
11015 fn hessian_vector_product_respects_maximize_sign() {
11016 let obj = bin(BinOp::Pow, v(0), c(2.0));
11017 let mut p = parts(1, obj, Vec::new());
11018 p.minimize = false;
11019 let prob = NlProblem::from_expressions(p).expect("build");
11020 let mut t = NlTnlp::try_new(prob).expect("tnlp");
11021 t.get_nlp_info().unwrap();
11022
11023 // max x0^2 is minimized as -x0^2, so ∇² = -2.
11024 let mut out = vec![0.0; 1];
11025 t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
11026 .expect("hvp");
11027 assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
11028 }
11029
11030 // ---- gh#1042 R3: `|e|^p · |e|^q` merged at parse time ----------------
11031
11032 /// An unconstrained `.nl` model over `n` variables whose objective is
11033 /// the given expression-token body (one token per line).
11034 fn abs_pow_nl(n: usize, body: &[&str]) -> String {
11035 let mut s = format!(
11036 "g3 0 1 0\n {n} 0 1 0 0\n 0 1\n 0 0\n 0 {n} 0\n 0 0 0 1\n 0 0 0 0 0\n 0 0\n 0 0\n 0 0 0 0 0\nO0 0\n"
11037 );
11038 for t in body {
11039 s.push_str(t);
11040 s.push('\n');
11041 }
11042 s.push_str("b\n");
11043 for _ in 0..n {
11044 s.push_str("3\n");
11045 }
11046 s
11047 }
11048
11049 /// `(3·|x|^2)·|x|^0.85` as Vanderbei's `dallass` writes its head-loss
11050 /// terms. Read as written, the tape's product rule forms `0·∞` at
11051 /// `x = 0` and the Hessian is NaN there (measured; the gradient reads
11052 /// `0`), which fails the
11053 /// KKT factorization and forces a restoration entry (11 of `dallass`'s
11054 /// 12 on 0.13.0). Merged into `3·|x|^2.85` every value is unchanged and
11055 /// the derivatives at the kink are the finite limits, `0` and `0`.
11056 #[test]
11057 fn abs_pow_product_is_merged_and_its_derivatives_are_finite_at_the_kink() {
11058 let txt = abs_pow_nl(
11059 1,
11060 &[
11061 "o2", "o2", "n3", "o5", "o15", "v0", "n2", "o5", "o15", "v0", "n0.85",
11062 ],
11063 );
11064 let p = parse_nl_text(&txt).expect("parse");
11065 // The tree is `Mul(3, Pow(Abs(x0), 2.85))`.
11066 match &*p.obj_expr() {
11067 Expr::Binary(BinOp::Mul, k, f) => {
11068 assert!(matches!(**k, Expr::Const(c) if c == 3.0), "{k:?}");
11069 assert!(
11070 matches!(abs_pow_parts(f), Some((Expr::Var(0), q)) if q == 2.85),
11071 "{f:?}"
11072 );
11073 }
11074 other => panic!("not merged: {other:?}"),
11075 }
11076
11077 let mut t = NlTnlp::new(p);
11078 let info = t.get_nlp_info().unwrap();
11079 assert_eq!(info.nnz_h_lag, 1);
11080 let f = |t: &mut NlTnlp, x: f64| t.eval_f(&[x], true).unwrap();
11081 let g = |t: &mut NlTnlp, x: f64| {
11082 let mut g = [0.0];
11083 assert!(t.eval_grad_f(&[x], true, &mut g));
11084 g[0]
11085 };
11086 let h = |t: &mut NlTnlp, x: f64| {
11087 let mut v = [0.0];
11088 assert!(t.eval_h(
11089 Some(&[x]),
11090 true,
11091 1.0,
11092 None,
11093 true,
11094 SparsityRequest::Values { values: &mut v }
11095 ));
11096 v[0]
11097 };
11098 let (pw, k) = (2.85_f64, 3.0_f64);
11099 for &x in &[-2.0, -0.7, -1e-3, 0.0, 1e-3, 0.7, 2.0, 1e3] {
11100 let a = f64::abs(x);
11101 // The value is the product's: `k·|x|^2·|x|^0.85`.
11102 let want_f = k * a.powi(2) * a.powf(0.85);
11103 let want_g = k * pw * a.powf(pw - 1.0) * x.signum() * f64::from(u8::from(x != 0.0));
11104 let want_h = k * pw * (pw - 1.0) * a.powf(pw - 2.0);
11105 let (gf, gg, gh) = (f(&mut t, x), g(&mut t, x), h(&mut t, x));
11106 assert!(
11107 gf.is_finite() && gg.is_finite() && gh.is_finite(),
11108 "x={x}: {gf} {gg} {gh}"
11109 );
11110 let tol = |w: f64| 1e-13 * w.abs().max(1e-300);
11111 assert!(
11112 (gf - want_f).abs() <= tol(want_f),
11113 "f({x}) = {gf}, want {want_f}"
11114 );
11115 assert!(
11116 (gg - want_g).abs() <= tol(want_g),
11117 "g({x}) = {gg}, want {want_g}"
11118 );
11119 assert!(
11120 (gh - want_h).abs() <= tol(want_h),
11121 "h({x}) = {gh}, want {want_h}"
11122 );
11123 }
11124 // At the kink: exactly zero, not NaN.
11125 assert_eq!(
11126 (f(&mut t, 0.0), g(&mut t, 0.0), h(&mut t, 0.0)),
11127 (0.0, 0.0, 0.0)
11128 );
11129 // The Hessian matches a central difference of the gradient on both
11130 // sides of the kink.
11131 for &x in &[-1e-3, 1e-3] {
11132 let e = 1e-8;
11133 let fd = (g(&mut t, x + e) - g(&mut t, x - e)) / (2.0 * e);
11134 let hx = h(&mut t, x);
11135 assert!(
11136 (fd - hx).abs() <= 1e-6 * hx.abs(),
11137 "x={x}: fd {fd} vs h {hx}"
11138 );
11139 }
11140 }
11141
11142 /// Every near miss keeps the tree it always had: `Mul` of the two
11143 /// operands exactly as read. Different operands, a non-positive power, a
11144 /// bare (signed) base and a variable exponent are each a case where the
11145 /// merge would change a value, not only a derivative, or is not known to
11146 /// be the same function.
11147 #[test]
11148 fn abs_pow_near_misses_build_the_unmerged_tree() {
11149 let cases: [(&[&str], &[&str]); 5] = [
11150 // different operands
11151 (&["o5", "o15", "v0", "n2"], &["o5", "o15", "v1", "n0.85"]),
11152 // a negative power: `|0|^-1·|0|^2` is NaN, `|0|^1` is not
11153 (&["o5", "o15", "v0", "n-1"], &["o5", "o15", "v0", "n2"]),
11154 // a zero power
11155 (&["o5", "o15", "v0", "n0"], &["o5", "o15", "v0", "n2"]),
11156 // no abs: `x^0.5·x^0.5` is NaN for x < 0, `x^1` is not
11157 (&["o5", "v0", "n0.5"], &["o5", "v0", "n0.5"]),
11158 // a variable exponent
11159 (&["o5", "o15", "v0", "v1"], &["o5", "o15", "v0", "n2"]),
11160 ];
11161 for (a, b) in cases {
11162 let mut body = vec!["o2"];
11163 body.extend_from_slice(a);
11164 body.extend_from_slice(b);
11165 let p = parse_nl_text(&abs_pow_nl(2, &body)).expect("parse");
11166 let frag = |toks: &[&str]| {
11167 let txt: String = toks.iter().map(|t| format!("{t}\n")).collect();
11168 parse_body_fragment(&txt, 2, &[]).expect("fragment")
11169 };
11170 let want = Expr::Binary(BinOp::Mul, Box::new(frag(a)), Box::new(frag(b)));
11171 assert_eq!(
11172 format!("{:?}", p.obj_expr()),
11173 format!("{want:?}"),
11174 "near miss {body:?} was rewritten"
11175 );
11176 }
11177 }
11178
11179 /// The merge reaches into one `Mul` level on either side and keeps the
11180 /// remaining factors, so `|x|^2·(|x|^0.85·y)` and `(|x|^0.5·y)·|x|^1.5`
11181 /// become `|x|^2.85·y` and `|x|^2·y` — the latter lowered to a mul chain
11182 /// by the tape — with values unchanged.
11183 #[test]
11184 fn abs_pow_merge_keeps_the_other_factors() {
11185 for (body, pw) in [
11186 (
11187 &[
11188 "o2", "o5", "o15", "v0", "n2", "o2", "o5", "o15", "v0", "n0.85", "v1",
11189 ][..],
11190 2.85,
11191 ),
11192 (
11193 &[
11194 "o2", "o2", "o5", "o15", "v0", "n0.5", "v1", "o5", "o15", "v0", "n1.5",
11195 ][..],
11196 2.0,
11197 ),
11198 ] {
11199 let p = parse_nl_text(&abs_pow_nl(2, body)).expect("parse");
11200 match &*p.obj_expr() {
11201 Expr::Binary(BinOp::Mul, l, r) => {
11202 assert!(
11203 matches!(abs_pow_parts(l), Some((Expr::Var(0), q)) if q == pw),
11204 "{l:?}"
11205 );
11206 assert!(matches!(**r, Expr::Var(1)), "{r:?}");
11207 }
11208 other => panic!("not merged: {other:?}"),
11209 }
11210 let mut t = NlTnlp::new(p);
11211 for &(x, y) in &[(-1.3, 0.4), (0.0, 2.0), (2.5, -1.0)] {
11212 let want = f64::abs(x).powf(pw) * y;
11213 let got = t.eval_f(&[x, y], true).unwrap();
11214 assert!(
11215 (got - want).abs() <= 1e-13 * want.abs(),
11216 "{body:?} at ({x},{y}): {got} vs {want}"
11217 );
11218 }
11219 }
11220 }
11221}