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 => crate::nl_tape::atanh(va),
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 (fa, fb) = crate::nl_tape::atan2_d1(va, vb);
3450 grad_expr(a, x, seed * fa, grad);
3451 grad_expr(b, x, seed * fb, 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 => crate::nl_tape::div_u_ln10(1.0, va),
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 => crate::nl_tape::atan_d1(va),
3483 UnaryOp::Acos => crate::nl_tape::acos_d1(va),
3484 UnaryOp::Sinh => va.cosh(),
3485 UnaryOp::Cosh => va.sinh(),
3486 UnaryOp::Tanh => crate::nl_tape::tanh_d1(va),
3487 UnaryOp::Asin => crate::nl_tape::asin_d1(va),
3488 UnaryOp::Acosh => crate::nl_tape::acosh_d1(va),
3489 UnaryOp::Asinh => crate::nl_tape::asinh_d1(va),
3490 UnaryOp::Atanh => crate::nl_tape::atanh_d1(va),
3491 UnaryOp::Erf => crate::nl_tape::erf_d1(va),
3492 UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
3493 };
3494 grad_expr(a, x, seed * d, grad);
3495 }
3496 Expr::Sum(args) => {
3497 for arg in args {
3498 grad_expr(arg, x, seed, grad);
3499 }
3500 }
3501 // min/max are piecewise linear: the seed flows only through the
3502 // currently-active (smallest / largest) operand — a subgradient.
3503 // Ties resolve to the first such operand. Empty list: no operand,
3504 // no derivative (matches the ±inf eval fold).
3505 Expr::MinList(args) => {
3506 if let Some(k) = argmin_argmax(args, x, true) {
3507 grad_expr(&args[k], x, seed, grad);
3508 }
3509 }
3510 Expr::MaxList(args) => {
3511 if let Some(k) = argmin_argmax(args, x, false) {
3512 grad_expr(&args[k], x, seed, grad);
3513 }
3514 }
3515 // Comparisons and logical connectives are piecewise constant:
3516 // zero derivative, so no seed propagates into their operands.
3517 Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3518 // if-then-else: differentiate only the active branch. The
3519 // branch-switch discontinuity contributes no derivative.
3520 Expr::Cond { cond, then_, else_ } => {
3521 if eval_expr(cond, x) != 0.0 {
3522 grad_expr(then_, x, seed, grad);
3523 } else {
3524 grad_expr(else_, x, seed, grad);
3525 }
3526 }
3527 Expr::Cse(body) => grad_expr(body, x, seed, grad),
3528 Expr::Funcall { .. } => {
3529 panic!("grad_expr: AMPL imported function called without an external resolver")
3530 }
3531 }
3532}
3533
3534/// Walk `e` and insert every `Var(i)` index into `out`.
3535///
3536/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3537/// identity. Without that this is Θ(2^depth) on a DAG that shares
3538/// subexpressions — each reference re-walks the whole body — and presolve
3539/// calls this on every solve (`get_variables_linearity`). Skipping a
3540/// repeat visit cannot change the answer: `out` is a set, and a second
3541/// walk of the same body inserts exactly the indices the first already did.
3542/// [`collect_vars`] over a `V` body whose earlier `Cse` references already
3543/// have their variable sets in `cse_vars` (indexed through `index`): those are
3544/// read, not re-walked (gh#986 review). The recursion is through `e`'s own
3545/// syntax only, which the parser has just built on this stack.
3546fn collect_vars_seeded(
3547 e: &Expr,
3548 out: &mut BTreeSet<usize>,
3549 index: &std::collections::HashMap<*const Expr, usize>,
3550 cse_vars: &[Vec<u32>],
3551) {
3552 let mut kids: Vec<&Expr> = vec![e];
3553 while let Some(e) = kids.pop() {
3554 match e {
3555 Expr::Const(_) => {}
3556 Expr::Var(i) => {
3557 out.insert(*i);
3558 }
3559 Expr::Cse(body) => match index.get(&Arc::as_ptr(body)) {
3560 Some(&k) => out.extend(cse_vars[k].iter().map(|&v| v as usize)),
3561 None => collect_vars(body, out),
3562 },
3563 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3564 kids.push(a);
3565 kids.push(b);
3566 }
3567 Expr::Unary(_, a) | Expr::Not(a) => kids.push(a),
3568 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => kids.extend(args.iter()),
3569 Expr::Cond { cond, then_, else_ } => {
3570 kids.push(cond);
3571 kids.push(then_);
3572 kids.push(else_);
3573 }
3574 Expr::Funcall { args, .. } => {
3575 for a in args {
3576 if let FuncallArg::Real(e) = a {
3577 kids.push(e);
3578 }
3579 }
3580 }
3581 }
3582 }
3583}
3584
3585pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3586 // `HashSet::new` does not allocate until the first insert, so an
3587 // expression with no CSEs pays nothing for the memo.
3588 let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3589 collect_vars_memo(e, out, &mut seen);
3590}
3591
3592fn collect_vars_memo(
3593 e: &Expr,
3594 out: &mut BTreeSet<usize>,
3595 seen: &mut std::collections::HashSet<*const Expr>,
3596) {
3597 match e {
3598 Expr::Const(_) => {}
3599 Expr::Var(i) => {
3600 out.insert(*i);
3601 }
3602 Expr::Binary(_, a, b) => {
3603 collect_vars_memo(a, out, seen);
3604 collect_vars_memo(b, out, seen);
3605 }
3606 Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3607 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3608 for a in args {
3609 collect_vars_memo(a, out, seen);
3610 }
3611 }
3612 // Collect from every child, including the condition: even
3613 // though the comparison/branch-test contributes no derivative,
3614 // the variables it reads are genuinely "used" by the problem,
3615 // and being conservative here only ever adds structural zeros
3616 // to the Jacobian/Hessian (never drops a real nonzero).
3617 Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3618 collect_vars_memo(a, out, seen);
3619 collect_vars_memo(b, out, seen);
3620 }
3621 Expr::Not(a) => collect_vars_memo(a, out, seen),
3622 Expr::Cond { cond, then_, else_ } => {
3623 collect_vars_memo(cond, out, seen);
3624 collect_vars_memo(then_, out, seen);
3625 collect_vars_memo(else_, out, seen);
3626 }
3627 Expr::Cse(body) => {
3628 if seen.insert(Arc::as_ptr(body)) {
3629 collect_vars_memo(body, out, seen);
3630 }
3631 }
3632 Expr::Funcall { args, .. } => {
3633 for a in args {
3634 if let FuncallArg::Real(e) = a {
3635 collect_vars_memo(e, out, seen);
3636 }
3637 }
3638 }
3639 }
3640}
3641
3642// --------------------------------------------------------------------
3643// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3644// Jacobian, and Hessian. Built once at construction; every solve-time
3645// callback is a tape sweep, no expression-tree recursion.
3646// --------------------------------------------------------------------
3647
3648/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3649/// After a directional Hessian-vector product `compressed = H · s_c`,
3650/// the entry at row `row` came uniquely from column `col` (because
3651/// no two columns of color `c` share any nonzero row), so we
3652/// scatter `compressed[row]` into `values[hess_idx]`.
3653#[derive(Debug, Clone)]
3654struct ColorWrite {
3655 row: u32,
3656 hess_idx: u32,
3657}
3658
3659/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3660/// **shared prelude** holding every CSE body referenced by two or more
3661/// summands, evaluated once per sweep.
3662///
3663/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3664/// defined variable (`V` segment) referenced from many rows is re-emitted —
3665/// and re-evaluated — once per reference. That is invisible on most models
3666/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3667/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3668/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3669/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3670/// search. See pounce#476.
3671///
3672/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3673/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3674/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3675/// overhead the flat tapes do not, so each derivative order has to earn
3676/// its switch.
3677#[derive(Debug, Clone)]
3678struct ConHybrid {
3679 tape: HybridTape,
3680 /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3681 /// Length `m + 1`.
3682 row_start: Vec<usize>,
3683 /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3684 prelude_vals: Vec<f64>,
3685 /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3686 local_vals: Vec<f64>,
3687 /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3688 /// value arenas above. `gradient_summand` zeroes only the slots a
3689 /// summand actually reaches, so these are allocated once and reused.
3690 local_adj: Vec<f64>,
3691 prelude_adj: Vec<f64>,
3692 /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3693 /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3694 /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3695 use_for_jac: bool,
3696 /// Whether `eval_h` routes the constraint block through the shared
3697 /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3698 use_for_hess: bool,
3699 // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3700 // `try_new` after the Hessian coloring exists; cheap enough (a few
3701 // index tables plus one f64 per local op) to build whenever the
3702 // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3703 /// Forward values of every summand, packed at
3704 /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3705 /// fills it; every color then reuses the values, mirroring the flat
3706 /// path's forward-once-per-tape structure.
3707 local_vals_all: Vec<f64>,
3708 /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3709 local_off: Vec<usize>,
3710 /// Constraint row of each summand (the inverse of `row_start`), for
3711 /// the `λ[row]` weight lookup.
3712 summand_row: Vec<u32>,
3713 /// Per color: the summands whose variables fall in that color — the
3714 /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3715 /// exactly the live (color, summand) pairs.
3716 hess_color_summands: Vec<Vec<u32>>,
3717 /// Per color: the prelude slots that color's summands actually reach,
3718 /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3719 /// Both prelude sweeps run once per color, so walking the whole
3720 /// prelude each time would cost `n_colors × |prelude|` where the
3721 /// op-ratio gate assumes `|prelude|`; iterating the union of the
3722 /// color's `prelude_reach` sets makes the cost proportional to what
3723 /// is used, so the gate does not need an `n_colors` term. Stored as
3724 /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3725 /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3726 /// this struct is the one #552 made O(n²) by holding per-color dense
3727 /// arrays.
3728 hess_color_reach: Vec<u32>,
3729 hess_color_reach_off: Vec<usize>,
3730 /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3731 prelude_dot: Vec<f64>,
3732 /// First-/second-order prelude adjoint accumulators. NOT shared
3733 /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3734 /// between-colors invariant (`prelude_reverse_directional`'s
3735 /// consume-and-zero contract), while `gradient_summand` zeroes only
3736 /// the slots it is about to use and leaves them dirty afterwards —
3737 /// sharing the buffer would seed a later `eval_h` with a stale
3738 /// Jacobian adjoint.
3739 hess_prelude_adj: Vec<f64>,
3740 prelude_adj_dot: Vec<f64>,
3741 /// Local tangent / second-order adjoint arenas, sized to
3742 /// `tape.max_summand_ops()`.
3743 local_dot: Vec<f64>,
3744 local_adj_dot: Vec<f64>,
3745}
3746
3747/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3748/// shared-CSE prelude.
3749///
3750/// `eval_g` takes the hybrid path unconditionally because it only needs
3751/// *values*: the prelude is swept once for the whole constraint block and
3752/// the saving is the full op-count ratio. The Jacobian is different. Each
3753/// row needs its own gradient, so only the forward sweep can be shared —
3754/// the reverse sweep still walks each summand's `prelude_reach`
3755/// separately, and it pays a per-op cost the flat tape does not: a nested
3756/// `SummandOp` dispatch and an indirected walk over a reach list instead
3757/// of a straight loop over a contiguous `Vec<TapeOp>`.
3758///
3759/// So the hybrid Jacobian wins only when the shared bodies are large
3760/// enough for the halved forward sweep to outweigh that overhead.
3761/// Measured on chain models at CSE redundancy 40, varying the body size
3762/// (`eval_jac_g`, flat → hybrid):
3763///
3764/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3765/// |---|---|---|---|---|---|---|---|---|
3766/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3767///
3768/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3769/// a model that does not clearly benefit stays on the flat path. For
3770/// reference `robot_a` (#476) measures 4.03×.
3771const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3772
3773/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3774/// block through the shared-CSE prelude (issue #557).
3775///
3776/// The Hessian shares **both** second-order sweeps of the prelude, not just
3777/// the forward one: the coloring hands every summand of a color the same
3778/// seed vector, so the prelude forward tangent runs once per color, and —
3779/// because reverse-over-tangent is linear in its adjoint seeds — the
3780/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3781/// unit-weight prelude reverse sweep per color. That is why its crossover
3782/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3783/// Jacobian can only share the forward half, and per-row gradients forbid
3784/// batching its reverse sweeps at all.
3785///
3786/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3787/// bodies), varying the body size — the same protocol as the Jacobian
3788/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3789/// flat/hybrid pairs per point**, with the observed range, because
3790/// single runs on a shared machine are not reproducible to the precision
3791/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3792/// single ratio:
3793///
3794/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3795/// |---|---|---|---|---|---|---|---|---|
3796/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3797/// | 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 |
3798///
3799/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3800/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3801/// break-even and individual runs lose. Setting it there costs only a
3802/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3803/// bit-identically, so a gate placed too high is merely conservative while
3804/// one placed too low risks a real regression. For reference `robot_a`
3805/// (#476) measures 4.03×.
3806const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3807
3808// `Clone` supports the batched-solve path (pounce#126): one parsed
3809// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3810// the clone is cheap relative to a solve) and each clone gets its own
3811// bound / starting-point overrides via [`NlTnlp::variant`].
3812#[derive(Debug, Clone)]
3813pub struct NlTnlp {
3814 prob: NlProblem,
3815 /// Per-summand objective tapes (one `Tape` per top-level
3816 /// summand after `split_top_sums`).
3817 obj_tapes: Vec<Tape>,
3818 /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3819 /// one `Tape` per summand of constraint `i`.
3820 con_tapes: Vec<Vec<Tape>>,
3821 /// Constraint-block tape with a **shared** CSE prelude, used by
3822 /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3823 /// `eval_g` on the per-summand `con_tapes` above.
3824 con_hybrid: Option<ConHybrid>,
3825 /// The degree-≤2 objective and rows, evaluated from their constant
3826 /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3827 /// has an **empty** `con_tapes` entry, so every loop over the tapes
3828 /// naturally contributes nothing for it and only the sites that consult
3829 /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3830 /// `eval_h`, and `hessian_vector_products` — the last of which the design
3831 /// note's list of five omitted, and which is the one where an omission
3832 /// would be silent. Empty for a model with nothing recognized, in which
3833 /// case everything below behaves bit for bit as it did before this
3834 /// existed.
3835 quad: QuadraticStructure,
3836 /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3837 /// Empty when `quad` is — the pattern is then all tape. The coloring is
3838 /// built over this subset alone, which is what stops one dense quadratic
3839 /// block from forcing the color count to `n` for the benefit of tapes
3840 /// that no longer exist.
3841 h_tape_mask: Vec<bool>,
3842 /// Lower-triangle Hessian sparsity (row >= col), one entry per
3843 /// structurally nonzero second derivative in the Lagrangian.
3844 h_irow: Vec<i32>,
3845 h_jcol: Vec<i32>,
3846 /// Per-row sorted variable indices for the constraint Jacobian.
3847 jac_cols: Vec<Vec<usize>>,
3848 jac_nnz: usize,
3849 /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3850 /// is in color `c`, else `0.0`. Each color is a set of
3851 /// variables whose Hessian columns have pairwise-disjoint
3852 /// nonzero rows; one directional H·s product per color
3853 /// recovers all those columns simultaneously. Dense for
3854 /// O(1) lookup in the per-op forward tangent.
3855 seeds: Vec<Vec<f64>>,
3856 /// Per-color decoding table: for each `(row, hess_idx)` entry,
3857 /// scatter `compressed_c[row] -> values[hess_idx]` after the
3858 /// per-color directional product.
3859 decoding: Vec<Vec<ColorWrite>>,
3860 /// For each objective tape: the distinct colors of vars it
3861 /// references. Lets us skip tape × color pairs where the tape
3862 /// has zero overlap with the color's seed.
3863 obj_tape_colors: Vec<Vec<u32>>,
3864 /// Same as `obj_tape_colors` but per constraint × summand.
3865 con_tape_colors: Vec<Vec<Vec<u32>>>,
3866 /// The same work, color-major, for the parallel flat Hessian path.
3867 hess_color_index: HessColorIndex,
3868 /// Offset of each row's run in the Jacobian `values` array, length
3869 /// `m + 1`. Lets a parallel row group take one contiguous slice.
3870 jac_row_off: Vec<u32>,
3871 /// Per-worker scratch for the parallel Jacobian, kept across evaluations:
3872 /// each holds a length-`n` gradient row, too big to allocate per call.
3873 #[cfg(not(target_arch = "wasm32"))]
3874 jac_scratch_pool: Vec<JacScratch>,
3875 /// Color of each variable's Hessian column, `u32::MAX` for a column
3876 /// that needs no pass of its own. Kept so
3877 /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3878 /// pass in `compressed`.
3879 var_color: Vec<u32>,
3880 /// Columns peeled out of the conflict structure and given singleton
3881 /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3882 peeled_cols: Vec<u32>,
3883 final_x: Option<Vec<Number>>,
3884 final_obj: Number,
3885 /// Converged constraint multipliers (length `m`, original `.nl` row
3886 /// order, user convention), captured from the same `finalize_solution`
3887 /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3888 /// block without re-deriving it from the algorithm's internal `y_c` /
3889 /// `y_d` split and scaling.
3890 final_lambda: Option<Vec<Number>>,
3891 /// Converged bound multipliers (length `n` each, Ipopt's internal
3892 /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3893 /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3894 /// reads for reduced costs.
3895 final_z_l: Option<Vec<Number>>,
3896 final_z_u: Option<Vec<Number>>,
3897 /// Per-row Jacobian accumulator (length n).
3898 scratch_row_grad: Vec<f64>,
3899 /// Scratch buffers for `Tape::hessian_directional` (each sized
3900 /// to `max_tape_n`).
3901 vals_scratch: Vec<f64>,
3902 dot_scratch: Vec<f64>,
3903 adj_scratch: Vec<f64>,
3904 adj_dot_scratch: Vec<f64>,
3905 /// Per-color compressed Hessian-vector results, sized to
3906 /// `prob.n`. Reused across `eval_h` calls but allocated once.
3907 compressed: Vec<Vec<f64>>,
3908 /// Per-direction "carries signal" mask for `hessian_vector_products`,
3909 /// kept here rather than allocated per call. This crate holds an
3910 /// explicit no-per-call-allocation line on the tape sweeps (see
3911 /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3912 /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3913 /// Krylov iteration.
3914 hvp_live: Vec<bool>,
3915 /// Model-derived scaling factors (gh #703), computed on demand by
3916 /// [`NlTnlp::enable_curvature_scaling`] and served through
3917 /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3918 /// the state every solve that does not select `curvature-based` stays
3919 /// in — computing them costs a pass over every stored Hessian entry
3920 /// and nothing else reads them.
3921 curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3922}
3923
3924// ---------------------------------------------------------------------
3925// Human-readable equation rendering (`print equation` in the debugger).
3926//
3927// Turns a parsed constraint back into infix text using the model's
3928// variable / constraint names, so the debugger can show the actual
3929// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3930// bare row index. This is the "print the specific equation, with
3931// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3932// argue makes equation-oriented model diagnostics actionable.
3933//
3934// The renderer is intentionally separate from the evaluation `Tape`:
3935// tapes are lossy for display (CSEs flattened, externals opaque),
3936// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3937// ---------------------------------------------------------------------
3938
3939/// Binding strength for parenthesization. Higher binds tighter.
3940const P_ADD: u8 = 10;
3941const P_MUL: u8 = 20;
3942const P_NEG: u8 = 30;
3943const P_POW: u8 = 40;
3944const P_ATOM: u8 = 100;
3945
3946/// Format a numeric literal compactly: integers without a trailing `.0`,
3947/// everything else via the shortest round-tripping `f64` form.
3948fn fmt_num(x: Number) -> String {
3949 if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3950 format!("{}", x as i64)
3951 } else {
3952 format!("{x}")
3953 }
3954}
3955
3956/// Display label for variable `i`: its `.col` name when present, else
3957/// `x[i]`.
3958fn var_label(i: usize, var_names: &[String]) -> String {
3959 match var_names.get(i) {
3960 Some(s) if !s.is_empty() => s.clone(),
3961 _ => format!("x[{i}]"),
3962 }
3963}
3964
3965/// Precedence of an expression's top operator (for child wrapping).
3966fn expr_prec(e: &Expr) -> u8 {
3967 match e {
3968 Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3969 Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3970 Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3971 Expr::Binary(BinOp::Pow, ..) => P_POW,
3972 Expr::Cse(inner) => expr_prec(inner),
3973 // Everything else renders as an atom / `f(...)` form.
3974 _ => P_ATOM,
3975 }
3976}
3977
3978/// Render an expression as infix text, using `var_names` for variable
3979/// labels where available (`x[i]` otherwise).
3980///
3981/// The debugger reaches the renderer through the constraint/objective
3982/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3983/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3984/// able to *see* the expression you just built is most of the debugging
3985/// story.
3986///
3987/// `Cse` bodies are inlined at every occurrence, so the output of a
3988/// heavily-shared DAG can be far larger than the DAG itself. Callers
3989/// rendering user-built expressions should bound the input first.
3990pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3991 render_expr(e, var_names, &[])
3992}
3993
3994/// Render `e`, wrapping in parentheses iff its precedence is looser than
3995/// `min_prec`.
3996fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
3997 let s = render_expr(e, vn, funcs);
3998 if expr_prec(e) < min_prec {
3999 format!("({s})")
4000 } else {
4001 s
4002 }
4003}
4004
4005fn unary_name(op: UnaryOp) -> &'static str {
4006 match op {
4007 UnaryOp::Neg => "-",
4008 UnaryOp::Sqrt => "sqrt",
4009 UnaryOp::Log => "log",
4010 UnaryOp::Exp => "exp",
4011 UnaryOp::Abs => "abs",
4012 UnaryOp::Sin => "sin",
4013 UnaryOp::Cos => "cos",
4014 UnaryOp::Log10 => "log10",
4015 UnaryOp::Tan => "tan",
4016 UnaryOp::Atan => "atan",
4017 UnaryOp::Acos => "acos",
4018 UnaryOp::Sinh => "sinh",
4019 UnaryOp::Cosh => "cosh",
4020 UnaryOp::Tanh => "tanh",
4021 UnaryOp::Asin => "asin",
4022 UnaryOp::Acosh => "acosh",
4023 UnaryOp::Asinh => "asinh",
4024 UnaryOp::Atanh => "atanh",
4025 UnaryOp::Erf => "erf",
4026 // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
4027 // the rendered text is read by humans debugging a model and must not
4028 // imply a sign the op does not have.
4029 UnaryOp::XLogX => "xlogx",
4030 }
4031}
4032
4033fn cmp_sym(op: CmpOp) -> &'static str {
4034 match op {
4035 CmpOp::Lt => "<",
4036 CmpOp::Le => "<=",
4037 CmpOp::Eq => "==",
4038 CmpOp::Ge => ">=",
4039 CmpOp::Gt => ">",
4040 CmpOp::Ne => "!=",
4041 }
4042}
4043
4044/// Append an additive sub-term with a tidy sign: a rendered term that
4045/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
4046/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
4047/// the term is itself a sum. The first term is emitted verbatim.
4048fn push_additive(out: &mut String, rendered: &str, first: bool) {
4049 if first {
4050 out.push_str(rendered);
4051 } else if let Some(rest) = rendered.strip_prefix('-') {
4052 out.push_str(" - ");
4053 out.push_str(rest);
4054 } else {
4055 out.push_str(" + ");
4056 out.push_str(rendered);
4057 }
4058}
4059
4060/// Render an [`Expr`] DAG to infix text using model names.
4061fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
4062 match e {
4063 Expr::Const(c) => fmt_num(*c),
4064 Expr::Var(i) => var_label(*i, vn),
4065 Expr::Binary(op, l, r) => match op {
4066 BinOp::Add => {
4067 let mut s = render_prec(l, P_ADD, vn, funcs);
4068 push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
4069 s
4070 }
4071 // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
4072 BinOp::Sub => format!(
4073 "{} - {}",
4074 render_prec(l, P_ADD, vn, funcs),
4075 render_prec(r, P_ADD + 1, vn, funcs)
4076 ),
4077 BinOp::Mul => format!(
4078 "{}*{}",
4079 render_prec(l, P_MUL, vn, funcs),
4080 render_prec(r, P_MUL, vn, funcs)
4081 ),
4082 BinOp::Div => format!(
4083 "{}/{}",
4084 render_prec(l, P_MUL, vn, funcs),
4085 render_prec(r, P_MUL + 1, vn, funcs)
4086 ),
4087 // Pow is right-associative: tighten the left operand instead.
4088 BinOp::Pow => format!(
4089 "{}^{}",
4090 render_prec(l, P_POW + 1, vn, funcs),
4091 render_prec(r, P_POW, vn, funcs)
4092 ),
4093 BinOp::Atan2 => format!(
4094 "atan2({}, {})",
4095 render_expr(l, vn, funcs),
4096 render_expr(r, vn, funcs)
4097 ),
4098 BinOp::CEntropy => format!(
4099 "centropy({}, {})",
4100 render_expr(l, vn, funcs),
4101 render_expr(r, vn, funcs)
4102 ),
4103 },
4104 Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
4105 Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
4106 Expr::Sum(xs) => {
4107 if xs.is_empty() {
4108 "0".to_string()
4109 } else {
4110 let mut s = String::new();
4111 for (k, x) in xs.iter().enumerate() {
4112 push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
4113 }
4114 s
4115 }
4116 }
4117 Expr::Cse(inner) => render_expr(inner, vn, funcs),
4118 Expr::Funcall { id, args } => {
4119 let name = funcs
4120 .iter()
4121 .find(|f| f.id == *id)
4122 .map(|f| f.name.clone())
4123 .unwrap_or_else(|| format!("extern#{id}"));
4124 let parts: Vec<String> = args
4125 .iter()
4126 .map(|a| match a {
4127 FuncallArg::Real(x) => render_expr(x, vn, funcs),
4128 FuncallArg::Str(s) => format!("{s:?}"),
4129 })
4130 .collect();
4131 format!("{name}({})", parts.join(", "))
4132 }
4133 Expr::Compare(op, a, b) => format!(
4134 "({} {} {})",
4135 render_expr(a, vn, funcs),
4136 cmp_sym(*op),
4137 render_expr(b, vn, funcs)
4138 ),
4139 Expr::And(a, b) => format!(
4140 "({} && {})",
4141 render_expr(a, vn, funcs),
4142 render_expr(b, vn, funcs)
4143 ),
4144 Expr::Or(a, b) => format!(
4145 "({} || {})",
4146 render_expr(a, vn, funcs),
4147 render_expr(b, vn, funcs)
4148 ),
4149 Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
4150 Expr::Cond { cond, then_, else_ } => format!(
4151 "if({}, {}, {})",
4152 render_expr(cond, vn, funcs),
4153 render_expr(then_, vn, funcs),
4154 render_expr(else_, vn, funcs)
4155 ),
4156 Expr::MinList(xs) => format!(
4157 "min({})",
4158 xs.iter()
4159 .map(|x| render_expr(x, vn, funcs))
4160 .collect::<Vec<_>>()
4161 .join(", ")
4162 ),
4163 Expr::MaxList(xs) => format!(
4164 "max({})",
4165 xs.iter()
4166 .map(|x| render_expr(x, vn, funcs))
4167 .collect::<Vec<_>>()
4168 .join(", ")
4169 ),
4170 }
4171}
4172
4173/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
4174/// `a + -2*b`). Returns `""` when there are no linear terms.
4175fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
4176 let mut out = String::new();
4177 // The `.nl` linear part carries an entry for every variable in the
4178 // row's Jacobian, including a 0 coefficient for variables that appear
4179 // only *nonlinearly* (they're rendered in the nonlinear part). Skip
4180 // those zeros so the equation reads as written, not as a sparsity map.
4181 let mut first = true;
4182 for (var, coef) in linear {
4183 if *coef == 0.0 {
4184 continue;
4185 }
4186 let neg = *coef < 0.0;
4187 let mag = coef.abs();
4188 let term = if mag == 1.0 {
4189 var_label(*var, vn)
4190 } else {
4191 format!("{}*{}", fmt_num(mag), var_label(*var, vn))
4192 };
4193 if first {
4194 if neg {
4195 out.push('-');
4196 }
4197 out.push_str(&term);
4198 first = false;
4199 } else {
4200 out.push_str(if neg { " - " } else { " + " });
4201 out.push_str(&term);
4202 }
4203 }
4204 out
4205}
4206
4207/// Render the constraint body (linear + nonlinear parts combined).
4208fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
4209 let mut s = render_linear(linear, &prob.var_names);
4210 let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
4211 if !nl_is_zero {
4212 let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
4213 if s.is_empty() {
4214 s = nl;
4215 } else {
4216 push_additive(&mut s, &nl, false);
4217 }
4218 }
4219 if s.is_empty() {
4220 s = "0".to_string();
4221 }
4222 s
4223}
4224
4225/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
4226/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
4227/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
4228pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
4229 // Diagnostic path: a recognized body has no tree, so this is one of
4230 // the places that pays to rebuild one. It runs once per rendered row.
4231 let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
4232 let lo = prob.g_l[k];
4233 let hi = prob.g_u[k];
4234 const INF: Number = 1.0e19;
4235 let has_lo = lo > -INF;
4236 let has_hi = hi < INF;
4237 match (has_lo, has_hi) {
4238 (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
4239 (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
4240 (true, false) => format!("{body} >= {}", fmt_num(lo)),
4241 (false, true) => format!("{body} <= {}", fmt_num(hi)),
4242 (false, false) => format!("{body} (free)"),
4243 }
4244}
4245
4246/// Render every constraint to text, index-aligned to `g` (original `.nl`
4247/// row order). Used to build the debugger's static equation book.
4248pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
4249 (0..prob.m)
4250 .map(|k| render_constraint_equation(prob, k))
4251 .collect()
4252}
4253
4254/// Structural sparsity of the constraint Jacobian as flat 0-based
4255/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
4256/// structurally depends on — the union of its linear support and the
4257/// `Var(i)` indices appearing anywhere in its nonlinear tree
4258/// ([`collect_vars`]). Sorted and deduplicated within each row.
4259///
4260/// This is the input to the debugger's Dulmage–Mendelsohn
4261/// structural-rank check (`diagnose`), which names the over-determined
4262/// (candidate redundant / inconsistent) equations and under-determined
4263/// variables. Naming the dependent rows — rather than reporting
4264/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
4265/// equation-oriented model debugging. See
4266/// <https://doi.org/10.69997/sct.147875>.
4267pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
4268 let mut irow: Vec<Index> = Vec::new();
4269 let mut jcol: Vec<Index> = Vec::new();
4270 let mut support: BTreeSet<usize> = BTreeSet::new();
4271 for k in 0..prob.m {
4272 support.clear();
4273 for &(j, _coef) in &prob.con_linear[k] {
4274 support.insert(j);
4275 }
4276 prob.con_nonlinear[k].collect_vars(&mut support);
4277 for &j in &support {
4278 irow.push(k as Index);
4279 jcol.push(j as Index);
4280 }
4281 }
4282 (irow, jcol)
4283}
4284
4285/// Flatten an additive expression tree into independent summand
4286/// expressions, each of which becomes its own Hessian tape.
4287///
4288/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
4289/// each summand tape once *per color the summand touches*, so the
4290/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
4291/// small (few variables → few colors) is what makes a sparse Hessian
4292/// cheap. A single fused tape spanning all `n` variables, by
4293/// contrast, is walked once per color → `O(n · tape_len)`, which on a
4294/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
4295/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
4296///
4297/// We therefore descend through the *affine* envelope of the sum, not
4298/// just `+`/`Sum`:
4299///
4300/// * `Neg(x)` → split `x`, negate each summand
4301/// * `Sub(l, r)` → split `l`; split `r`, negate each summand
4302/// * `c * x` / `x * c` → split `x`, scale each summand by `c`
4303/// * `x / c` → split `x`, scale each summand by `1/c`
4304///
4305/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
4306/// least-squares / max-entropy shapes) still decomposes to its leaf
4307/// terms instead of collapsing into one giant tape. The carried
4308/// `factor` is materialised onto each leaf only when it differs from
4309/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
4310/// is unchanged and the per-summand op count grows by at most one.
4311fn split_top_sums(expr: &Expr) -> Vec<Expr> {
4312 let mut out = Vec::new();
4313 fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4314 if factor == 1.0 {
4315 out.push(e.clone());
4316 } else if factor == -1.0 {
4317 out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
4318 } else {
4319 out.push(Expr::Binary(
4320 BinOp::Mul,
4321 Box::new(Expr::Const(factor)),
4322 Box::new(e.clone()),
4323 ));
4324 }
4325 }
4326 fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4327 match e {
4328 Expr::Sum(terms) => {
4329 for t in terms {
4330 go(t, factor, out);
4331 }
4332 }
4333 Expr::Binary(BinOp::Add, l, r) => {
4334 go(l, factor, out);
4335 go(r, factor, out);
4336 }
4337 Expr::Binary(BinOp::Sub, l, r) => {
4338 go(l, factor, out);
4339 go(r, -factor, out);
4340 }
4341 Expr::Unary(UnaryOp::Neg, x) => {
4342 go(x, -factor, out);
4343 }
4344 // Affine scaling: distribute a constant coefficient into
4345 // the summands so a leading `c·(Σ …)` still splits.
4346 Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
4347 (Expr::Const(c), _) => go(r, factor * c, out),
4348 (_, Expr::Const(c)) => go(l, factor * c, out),
4349 _ => push_leaf(e, factor, out),
4350 },
4351 Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
4352 Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
4353 _ => push_leaf(e, factor, out),
4354 },
4355 _ => push_leaf(e, factor, out),
4356 }
4357 }
4358 go(expr, 1.0, &mut out);
4359 if out.is_empty() {
4360 out.push(Expr::Const(0.0));
4361 }
4362 out
4363}
4364
4365/// Greedy column coloring of a symmetric sparsity pattern stored
4366/// as lower-triangle pairs.
4367///
4368/// Builds the column-intersection graph: columns `c1` and `c2` are
4369/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
4370/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
4371/// satisfies the direct-recovery condition for symmetric Hessians
4372/// (Coleman-Moré): for any color, the columns it contains have
4373/// pairwise disjoint row supports, so a single H·s product
4374/// recovers them all unambiguously.
4375///
4376/// Returns `(var_color, n_colors)` where `var_color[k]` is the
4377/// color assigned to variable `k`, or `u32::MAX` for variables
4378/// not in any Hessian pair (they contribute nothing and don't
4379/// need a color).
4380/// A column is treated as **dense** — and peeled out of the coloring —
4381/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
4382/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
4383/// factor keeps uniformly-dense Hessians (where every column looks like
4384/// every other) on the plain coloring path, and the absolute floor stops
4385/// a very sparse average from declaring a 10-entry column "dense".
4386const DENSE_COL_FACTOR: usize = 16;
4387const DENSE_COL_MIN: usize = 32;
4388/// Largest relative error a peeled column may inflict on the smallest
4389/// entry recovered from its pass before
4390/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
4391///
4392/// The ratio is a worst-case bound — the pass's roundoff floor over the
4393/// smallest entry read out of it — and it is a *loose* one, by an amount
4394/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
4395/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
4396/// measures 4e-16. The cut is therefore calibrated on the corpus, not
4397/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
4398/// models that peel anything, the highest bound on a column that recovers
4399/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
4400/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
4401/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
4402///
4403/// 1e-8 sits below both, which deliberately buys correctness with speed:
4404/// the errors are asymmetric, since a false veto costs one model a coloring
4405/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
4406/// while a missed veto costs a solve its certificate. Five of the 56 take a
4407/// veto they do not need; none takes a wrong answer. Raising this constant
4408/// to buy those five back would put the cut inside a 2.8x window measured
4409/// on two model families, which is not a margin worth trading a certificate
4410/// for.
4411const PEEL_MAX_REL_ERR: f64 = 1e-8;
4412/// Hard cap on how many columns get peeled, applied on top of the
4413/// pay-for-itself rule in [`select_peeled_cols`].
4414const MAX_PEELED_COLS: usize = 256;
4415
4416/// Lower bound on the color count that results from peeling `peeled`.
4417///
4418/// Peeling costs one color per peeled column. On what remains, any row
4419/// with `d` surviving entries makes those `d` columns pairwise
4420/// conflicting, so the greedy walk needs at least `d` colors. Hence
4421/// `|peeled| + max surviving row degree` is a lower bound on the total,
4422/// computable in one O(nnz) pass — no coloring required.
4423///
4424/// The bound is what makes the choice decidable at all: the plain
4425/// coloring cannot be run as a comparison baseline, because on the
4426/// one-dense-row case the plain walk is itself O(n^2) — precisely the
4427/// blowup peeling exists to avoid.
4428fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
4429 let mut deg = vec![0usize; n];
4430 for &(i, j) in lower_pairs {
4431 if peeled[i] || peeled[j] {
4432 continue;
4433 }
4434 deg[j] += 1;
4435 if i != j {
4436 deg[i] += 1;
4437 }
4438 }
4439 let n_peeled = peeled.iter().filter(|&&p| p).count();
4440 n_peeled + deg.iter().copied().max().unwrap_or(0)
4441}
4442
4443/// Choose which of the candidate dense columns to actually peel.
4444///
4445/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
4446/// top `k` candidates by degree, over a doubling ladder of `k` up to
4447/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
4448/// so peeling has to earn its colors.
4449///
4450/// **Why not just truncate an over-long candidate list.** Cutting the
4451/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
4452/// columns that miss the cut stay in the conflict structure, so the base
4453/// color count is untouched and the singleton colors are pure addition.
4454/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
4455/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
4456/// regression in exactly the quantity peeling exists to reduce. The
4457/// bound above sees it: peeling `k` of several thousand equal-degree
4458/// columns leaves the surviving max degree unchanged, so every `k > 0`
4459/// scores strictly worse than peeling nothing.
4460///
4461/// **Why not a simple degree rule.** "Peel only columns denser than some
4462/// fraction of `n`" would refuse three rows of degree 10,000 in a
4463/// 200,000-variable model, where peeling three columns takes the
4464/// coloring from >= 10,000 down to a handful. The win depends on what
4465/// peeling leaves behind, not on the peeled column's degree alone.
4466fn select_peeled_cols(
4467 n: usize,
4468 lower_pairs: &[(usize, usize)],
4469 deg: &[usize],
4470 mut candidates: Vec<usize>,
4471) -> Vec<usize> {
4472 if candidates.is_empty() {
4473 return candidates;
4474 }
4475 // Worst offenders first; they remove the most conflict per color spent.
4476 candidates.sort_unstable_by(|&a, &b| deg[b].cmp(°[a]).then(a.cmp(&b)));
4477 candidates.truncate(MAX_PEELED_COLS);
4478
4479 let mut mask = vec![false; n];
4480 let mut best_k = 0usize;
4481 // Peeling nothing: the bound is just the largest row degree.
4482 let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
4483
4484 // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
4485 // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
4486 // mask only ever gains entries, so each step just marks the new slice.
4487 let mut marked = 0usize;
4488 let mut k = 1usize;
4489 loop {
4490 let k_now = k.min(candidates.len());
4491 for &j in &candidates[marked..k_now] {
4492 mask[j] = true;
4493 }
4494 marked = k_now;
4495 let bound = peel_color_bound(n, lower_pairs, &mask);
4496 if bound < best_bound {
4497 best_bound = bound;
4498 best_k = k_now;
4499 }
4500 if k_now == candidates.len() {
4501 break;
4502 }
4503 k *= 2;
4504 }
4505
4506 candidates.truncate(best_k);
4507 candidates
4508}
4509
4510/// Greedy distance-1 coloring of the Hessian's column-intersection
4511/// graph, with **dense columns peeled out**.
4512///
4513/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
4514/// marks a column that needs no directional product of its own: either
4515/// it has no Hessian entries at all, or every entry it has is recovered
4516/// from a peeled column's pass (see below).
4517///
4518/// # Why peeling
4519///
4520/// The plain coloring rule is "two columns may share a color when they
4521/// have no common nonzero row". A single **dense row** — one variable
4522/// multiplying a sum over all the others, a total-cost variable, a
4523/// shared design parameter — puts a nonzero in *every* column at that
4524/// row, so every pair of columns conflicts and the greedy walk hands out
4525/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
4526/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
4527/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
4528/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
4529///
4530/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
4531/// own singleton color: one directional product with seed `e_d` recovers
4532/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
4533/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
4534/// at row `j` of that same pass. So row `d` no longer constrains any
4535/// other column's color and is dropped from the conflict structure, and
4536/// the remaining columns colour on their genuine sparsity. On the
4537/// one-dense-row case above this takes `n_colors` from `n` to a handful.
4538///
4539/// # What peeling costs, and `peel_veto`
4540///
4541/// Recovering `H[d, j]` from column `d`'s pass is exact in real
4542/// arithmetic but not in floating point: the pass is accumulated at the
4543/// scale of the whole dense column, so every entry read out of it carries
4544/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
4545/// ordinary path — column `j`'s own pass — would have left a floor of
4546/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
4547/// dense column is well scaled, and they do on every peel-firing model in
4548/// the benchmark corpus but one. Where a peeled column spans a wide
4549/// dynamic range, though, that floor swamps its small entries: a column
4550/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4551///
4552/// Structure cannot see this — it is a property of the values — so
4553/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4554/// and passes the offenders back here in `peel_veto`, which bars them
4555/// from being peeled again. A vetoed column is colored normally, its row
4556/// returns to the conflict structure, and its entries go back to the
4557/// accurate path.
4558fn greedy_hessian_coloring(
4559 n: usize,
4560 lower_pairs: &[(usize, usize)],
4561 peel_veto: &[bool],
4562) -> (Vec<u32>, usize, Vec<bool>) {
4563 if n == 0 {
4564 return (Vec::new(), 0, Vec::new());
4565 }
4566
4567 // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4568 // i >= j contributes row i to column j and row j to column i; a
4569 // diagonal contributes once.
4570 let mut deg = vec![0usize; n];
4571 for &(i, j) in lower_pairs {
4572 deg[j] += 1;
4573 if i != j {
4574 deg[i] += 1;
4575 }
4576 }
4577
4578 // Pick the dense columns to peel.
4579 let total: usize = deg.iter().sum();
4580 let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4581 let mut peeled = vec![false; n];
4582 let candidates: Vec<usize> = (0..n)
4583 .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4584 .collect();
4585 let dense = select_peeled_cols(n, lower_pairs, °, candidates);
4586 for &j in &dense {
4587 peeled[j] = true;
4588 }
4589
4590 // Conflict structure over the *non-peeled* columns only. Pairs with
4591 // a peeled endpoint are recovered from that endpoint's own pass, so
4592 // they neither need a color nor constrain one.
4593 let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4594 let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4595 for &(i, j) in lower_pairs {
4596 if peeled[i] || peeled[j] {
4597 continue;
4598 }
4599 col_rows[j].push(i as u32);
4600 row_cols[i].push(j as u32);
4601 if i != j {
4602 col_rows[i].push(j as u32);
4603 row_cols[j].push(i as u32);
4604 }
4605 }
4606
4607 let mut var_color = vec![u32::MAX; n];
4608 let mut forbidden = vec![u32::MAX; n + 1];
4609 let mut n_colors: u32 = 0;
4610
4611 for j in 0..n {
4612 // Peeled columns are colored below; a column with no surviving
4613 // Hessian entries needs no color at all.
4614 if peeled[j] || col_rows[j].is_empty() {
4615 continue;
4616 }
4617 // Mark colors used by any column sharing a row with `j`.
4618 // Row-of-col -> col-in-row visit pattern collects all
4619 // distance-1 neighbors in the column-intersection graph.
4620 for &r in &col_rows[j] {
4621 for &c in &row_cols[r as usize] {
4622 if c as usize == j {
4623 continue;
4624 }
4625 let cc = var_color[c as usize];
4626 if cc != u32::MAX {
4627 forbidden[cc as usize] = j as u32;
4628 }
4629 }
4630 }
4631 // First color not stamped with `j as u32`.
4632 let mut chosen: u32 = 0;
4633 while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4634 chosen += 1;
4635 }
4636 var_color[j] = chosen;
4637 if chosen + 1 > n_colors {
4638 n_colors = chosen + 1;
4639 }
4640 }
4641
4642 // One singleton color per peeled column, appended after the shared
4643 // ones so the non-peeled numbering is untouched.
4644 for &j in &dense {
4645 var_color[j] = n_colors;
4646 n_colors += 1;
4647 }
4648
4649 (var_color, n_colors as usize, peeled)
4650}
4651
4652/// Everything downstream of the Hessian coloring: seed vectors, the
4653/// per-color decode table, the per-tape color sets, and the shared-CSE
4654/// per-color summand / prelude-reach tables.
4655///
4656/// Split out of [`NlTnlp::new`] because
4657/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4658/// time, with a peel veto in hand, once it has seen real Hessian values.
4659fn build_color_tables(
4660 n: usize,
4661 m: usize,
4662 lower_pairs: &[(usize, usize)],
4663 tape_mask: &[bool],
4664 peel_veto: &[bool],
4665 obj_tapes: &[Tape],
4666 con_tapes: &[Vec<Tape>],
4667 con_hybrid: Option<&mut ConHybrid>,
4668) -> ColorTables {
4669 // Hessian column coloring. The chromatic number of the
4670 // column-intersection graph bounds how many directional
4671 // Hessian-vector products we need per `eval_h` call —
4672 // typically O(stencil) for PDE-mesh problems.
4673 //
4674 // Only the entries a *tape* can write take part. `tape_mask` is empty
4675 // when every entry is a tape entry (no quadratic structure), which is
4676 // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4677 // entries are excluded, since they are scattered from their stored
4678 // values and never read out of a directional product. A variable that
4679 // appears only in quadratic rows then has no color at all
4680 // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4681 // column with no surviving entries.
4682 let colored_pairs: Vec<(usize, usize)>;
4683 let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4684 lower_pairs
4685 } else {
4686 colored_pairs = lower_pairs
4687 .iter()
4688 .zip(tape_mask)
4689 .filter_map(|(p, &t)| t.then_some(*p))
4690 .collect();
4691 &colored_pairs
4692 };
4693 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4694
4695 // Per-color seed vectors (dense for O(1) Var lookup in
4696 // `Tape::hessian_directional`).
4697 let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4698 for (k, &c) in var_color.iter().enumerate() {
4699 if c != u32::MAX {
4700 seeds[c as usize][k] = 1.0;
4701 }
4702 }
4703
4704 // Per-color decoding table. For each lower-tri pair (i, j)
4705 // with i >= j, the entry belongs to column j's color: after
4706 // computing compressed_{c_j} = (H · s_{c_j}), the value at
4707 // row i is exactly H[i, j] (coloring guarantees no other
4708 // column in c_j has a nonzero at row i).
4709 // Built straight from `lower_pairs`, which is sorted, so each
4710 // color's table is in ascending `hess_idx` order and the decode
4711 // scatter walks `values` forward instead of hopping (the old
4712 // build drained a `HashMap`, whose iteration order is arbitrary).
4713 let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4714 for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4715 // An entry no tape writes has no pass to be decoded out of; the
4716 // quadratic scatter already put its value there.
4717 if !tape_mask.is_empty() && !tape_mask[idx] {
4718 continue;
4719 }
4720 // Which directional product recovers H[i, j]? Column `j`'s,
4721 // read at row `i` — except when `i` is a peeled column and
4722 // `j` is not: then `j` may have no color of its own, and the
4723 // entry is already in column `i`'s pass at row `j`, since
4724 // H[i, j] == H[j, i].
4725 let (c, row) = if peeled[i] && !peeled[j] {
4726 (var_color[i], j)
4727 } else {
4728 (var_color[j], i)
4729 };
4730 debug_assert!(
4731 c != u32::MAX,
4732 "Hessian pair ({i}, {j}) at index {idx} has no color"
4733 );
4734 decoding[c as usize].push(ColorWrite {
4735 row: row as u32,
4736 hess_idx: idx as u32,
4737 });
4738 }
4739
4740 // Per-tape distinct color set: for each tape, the colors
4741 // its variables fall into. `eval_h` loops over only these
4742 // (tape, color) pairs instead of n_tapes × n_colors.
4743 let tape_colors = |t: &Tape| -> Vec<u32> {
4744 let mut s: Vec<u32> = t
4745 .variables()
4746 .into_iter()
4747 .map(|v| var_color[v])
4748 .filter(|&c| c != u32::MAX)
4749 .collect();
4750 s.sort_unstable();
4751 s.dedup();
4752 s
4753 };
4754 let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4755 let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4756 .iter()
4757 .map(|row| row.iter().map(tape_colors).collect())
4758 .collect();
4759
4760 // Shared-CSE Hessian tables (issue #557): the per-color summand
4761 // lists, row lookup, and packed forward-value arena `eval_h`'s
4762 // hybrid path walks. Built whenever the hybrid tape is — not just
4763 // above the gate — so flipping `use_for_hess` on (tests, the
4764 // force env var) needs no extra setup; the cost is one f64 per
4765 // local op plus small index tables.
4766 if let Some(h) = con_hybrid {
4767 let n_sum = h.tape.n_summands();
4768 let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4769 let mut acc = 0usize;
4770 for s in &h.tape.summands {
4771 local_off.push(acc);
4772 acc += s.ops.len();
4773 }
4774 local_off.push(acc);
4775 h.local_vals_all = vec![0.0; acc];
4776 h.local_off = local_off;
4777
4778 let mut summand_row = vec![0u32; n_sum];
4779 for i in 0..m {
4780 for si in h.row_start[i]..h.row_start[i + 1] {
4781 summand_row[si] = i as u32;
4782 }
4783 }
4784 h.summand_row = summand_row;
4785
4786 // A summand's variable set (`all_vars`) equals its flat tape's,
4787 // so this is `con_tape_colors` inverted to color-major order —
4788 // the loop `eval_h` actually runs.
4789 let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4790 for (si, s) in h.tape.summands.iter().enumerate() {
4791 let mut cs: Vec<u32> = s
4792 .all_vars
4793 .iter()
4794 .map(|&v| var_color[v])
4795 .filter(|&c| c != u32::MAX)
4796 .collect();
4797 cs.sort_unstable();
4798 cs.dedup();
4799 for c in cs {
4800 by_color[c as usize].push(si as u32);
4801 }
4802 }
4803 // Per-color prelude reach: the union of `prelude_reach` over the
4804 // color's summands, ascending. A union of operand-closed
4805 // ascending sets is itself operand-closed and ascending, which is
4806 // exactly what the two prelude sweeps require. Deduped with an
4807 // epoch-tagged buffer so the build costs
4808 // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4809 // it saves — rather than `n_colors × |prelude|`.
4810 let np = h.tape.n_prelude_ops();
4811 let mut seen: Vec<u32> = vec![0; np];
4812 let mut epoch: u32 = 0;
4813 let mut reach: Vec<u32> = Vec::new();
4814 let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4815 for list in &by_color {
4816 reach_off.push(reach.len());
4817 epoch += 1;
4818 let start = reach.len();
4819 for &si in list {
4820 for &p in &h.tape.summands[si as usize].prelude_reach {
4821 if seen[p] != epoch {
4822 seen[p] = epoch;
4823 reach.push(p as u32);
4824 }
4825 }
4826 }
4827 reach[start..].sort_unstable();
4828 }
4829 reach_off.push(reach.len());
4830 h.hess_color_reach = reach;
4831 h.hess_color_reach_off = reach_off;
4832
4833 h.hess_color_summands = by_color;
4834 h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4835 h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4836 h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4837 h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4838 h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4839 }
4840
4841 ColorTables {
4842 var_color,
4843 n_colors,
4844 peeled_cols: peeled
4845 .iter()
4846 .enumerate()
4847 .filter(|(_, p)| **p)
4848 .map(|(j, _)| j as u32)
4849 .collect(),
4850 seeds,
4851 decoding,
4852 hess_color_index: HessColorIndex::build(n_colors, &con_tape_colors),
4853 obj_tape_colors,
4854 con_tape_colors,
4855 }
4856}
4857
4858/// The color-dependent half of an [`NlTnlp`], as built by
4859/// [`build_color_tables`].
4860struct ColorTables {
4861 var_color: Vec<u32>,
4862 n_colors: usize,
4863 /// Columns given a singleton color and dropped from the conflict
4864 /// structure. Small by construction (`MAX_PEELED_COLS`).
4865 peeled_cols: Vec<u32>,
4866 seeds: Vec<Vec<f64>>,
4867 decoding: Vec<Vec<ColorWrite>>,
4868 obj_tape_colors: Vec<Vec<u32>>,
4869 con_tape_colors: Vec<Vec<Vec<u32>>>,
4870 /// `con_tape_colors` inverted to color-major, CSR-packed: the (row, tape)
4871 /// pairs whose tape touches each color, and the objective tapes likewise.
4872 ///
4873 /// The flat `eval_h` walks tapes and, inside each, the colors it touches,
4874 /// so several rows accumulate into one `compressed[c]` — fine serially,
4875 /// unshareable across threads. Walking color-major makes each color's
4876 /// accumulator private to its task, at the cost of forwarding a tape once
4877 /// per color it touches instead of once (measured at 2.9 colors per tape
4878 /// on an AC-SCOPF model, against a directional sweep that costs several
4879 /// forwards, so ~20% more serial work for 28-way parallelism).
4880 ///
4881 /// Within a color the pairs stay in ascending (row, tape) order, which is
4882 /// the order the tape-major loop contributes them in, so the two walks
4883 /// accumulate identical sums in identical order — bit-for-bit, not merely
4884 /// within tolerance. `parallel_matches_serial_bit_for_bit` pins that.
4885 hess_color_index: HessColorIndex,
4886}
4887
4888/// Color-major (row, tape) work lists for the flat Hessian path. See
4889/// [`ColorTables::hess_color_index`].
4890#[derive(Debug, Default, Clone)]
4891pub(crate) struct HessColorIndex {
4892 /// Constraint (row, tape) pairs per color, CSR. The objective's tapes are
4893 /// deliberately absent: `eval_h` accumulates them into `compressed`
4894 /// *before* the constraint block and keeps doing so serially, which is
4895 /// what makes the parallel walk's per-color ordering identical to the
4896 /// serial one — objective first, then rows ascending.
4897 con_off: Vec<u32>,
4898 con_row: Vec<u32>,
4899 con_tape: Vec<u32>,
4900}
4901
4902impl HessColorIndex {
4903 fn build(n_colors: usize, con_tape_colors: &[Vec<Vec<u32>>]) -> Self {
4904 // Rows ascending, then tapes ascending: the tape-major loop's order.
4905 let mut con: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_colors];
4906 for (k, row) in con_tape_colors.iter().enumerate() {
4907 for (ti, colors) in row.iter().enumerate() {
4908 for &c in colors {
4909 con[c as usize].push((k as u32, ti as u32));
4910 }
4911 }
4912 }
4913 let mut out = HessColorIndex {
4914 con_off: Vec::with_capacity(n_colors + 1),
4915 ..Default::default()
4916 };
4917 out.con_off.push(0);
4918 for list in &con {
4919 for &(k, ti) in list {
4920 out.con_row.push(k);
4921 out.con_tape.push(ti);
4922 }
4923 out.con_off.push(out.con_row.len() as u32);
4924 }
4925 out
4926 }
4927
4928 /// The (row, tape) pairs of color `c`, as two parallel slices.
4929 fn con_pairs(&self, c: usize) -> (&[u32], &[u32]) {
4930 let (a, b) = (self.con_off[c] as usize, self.con_off[c + 1] as usize);
4931 (&self.con_row[a..b], &self.con_tape[a..b])
4932 }
4933}
4934
4935impl NlTnlp {
4936 /// Build the TNLP, panicking if AMPL external-function resolution fails.
4937 ///
4938 /// Kept for the many infallible call sites (tests) that operate on `.nl`
4939 /// models known to need no external libraries. Surfaces that can be
4940 /// handed an arbitrary user model — the CLI solve and `verify` paths, the
4941 /// Python `read_nl` binding — must call [`Self::try_new`] instead so a
4942 /// missing `$AMPLFUNC` library becomes an ordinary error rather than a
4943 /// panic (gh#1069: the CLI exited 101 with a backtrace hint).
4944 pub fn new(prob: NlProblem) -> Self {
4945 Self::try_new(prob)
4946 .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4947 }
4948
4949 /// Build the TNLP, returning an error (instead of panicking) when AMPL
4950 /// imported functions named by the model can't be resolved — e.g.
4951 /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4952 /// referenced function id isn't registered by any loaded library.
4953 pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4954 // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4955 // objective — the A/B reference for the constant-structure path,
4956 // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4957 // fast path's derivatives are checked against the ones they replace
4958 // on a real model, and how a suspected fast-path bug is bisected
4959 // against a reference that computes the same numbers a different way.
4960 Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4961 }
4962
4963 /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4964 /// explicitly on or off.
4965 ///
4966 /// The env var `try_new` reads is process-global, which is exactly wrong
4967 /// for the differential test that has to build the *same* model both ways
4968 /// and compare the derivatives — so the knob is a parameter here and the
4969 /// env var only chooses its default.
4970 pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4971 // Resolve any AMPL imported (external) functions. Walk every
4972 // nonlinear expression to collect the funcall ids actually
4973 // referenced; load the libraries named in $AMPLFUNC and bind
4974 // each id to its (library, registered-name) pair so the tape
4975 // builder can emit live `TapeOp::Funcall` ops.
4976 let mut referenced: BTreeSet<usize> = BTreeSet::new();
4977 // Only trees can carry a `Funcall`: the recognizer refuses one, so
4978 // a recognized body provably contains none.
4979 for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4980 if let Some(e) = body.tree() {
4981 super::nl_external::collect_funcall_ids(e, &mut referenced);
4982 }
4983 }
4984 let resolver = if referenced.is_empty() {
4985 super::nl_external::ExternalResolver::default()
4986 } else {
4987 super::nl_external::ExternalResolver::build_for_problem(
4988 &prob.imported_funcs,
4989 &referenced,
4990 )?
4991 };
4992
4993 // Recognize the degree-≤2 objective and rows *before* anything is
4994 // taped, because the win is not in evaluating the tape faster — it
4995 // is in never building it. On `qcqp500-3c` the ten quadratic rows
4996 // are 2.32 M monomials, one `Tape` each; recognizing them first
4997 // means those 2.32 M tapes, their color lists and their per-tape
4998 // sparsity sets are never allocated.
4999 //
5000 // A row that is trivially zero is left alone: it has no nonlinear
5001 // part to replace, and routing it through a (necessarily empty)
5002 // quadratic form would touch every model in the corpus to save
5003 // nothing.
5004 let mut quad = QuadraticStructure::new(prob.m);
5005 if use_quadratic {
5006 // `is_expanded_quadratic` is the accuracy gate, not an
5007 // optimization: it admits only forms whose read-out repeats the
5008 // additions the `.nl` writer already wrote. See its docs — and
5009 // note it is checked *before* recognition, so a factored form
5010 // costs one cheap structural walk rather than a full expansion.
5011 // Recognition is the parser's job now (gh #588, Q5) for the
5012 // bodies it could reach; `admitted_quad_form` reads its answer
5013 // back, and still walks a tree for the bodies that kept one —
5014 // a `from_expressions` model, a factored row rewound by the
5015 // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
5016 //
5017 // A form that gate refuses is offered the *factored* read-out
5018 // before it falls back to a tape (gh #673): the gate is a
5019 // verdict on the expansion, not on the body, and a sum of
5020 // squared residuals keeps its structure by keeping its squares.
5021 // `push_body_form` owns that order.
5022 if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
5023 quad.assign_objective(f);
5024 }
5025 for k in 0..prob.m {
5026 if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
5027 quad.assign_row(k, f);
5028 }
5029 }
5030 }
5031
5032 // Flatten objective and each constraint into independent
5033 // summands. Each summand becomes its own `Tape` (CSE bodies
5034 // are deduplicated within a tape via Rc identity in
5035 // `Tape::build`; bodies shared across summands are
5036 // duplicated, which we accept as a simplicity tradeoff).
5037 let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
5038 Vec::new()
5039 } else {
5040 split_top_sums(&prob.obj_expr())
5041 .iter()
5042 .map(|e| Tape::build_with_externals(e, &resolver))
5043 .collect()
5044 };
5045
5046 let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
5047 let mut con_roots: Vec<Expr> = Vec::new();
5048 let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
5049 for k in 0..prob.m {
5050 row_start.push(con_roots.len());
5051 // A row with a quadratic form contributes no tape and no
5052 // hybrid-tape root, so `con_tapes[k]` is empty and its
5053 // `row_start` range is empty too.
5054 if quad.row_form(k).is_some() {
5055 con_tapes.push(Vec::new());
5056 continue;
5057 }
5058 let summands = split_top_sums(&prob.con_expr(k));
5059 con_tapes.push(
5060 summands
5061 .iter()
5062 .map(|e| Tape::build_with_externals(e, &resolver))
5063 .collect(),
5064 );
5065 // Move (not clone) the split summands into the root list: their
5066 // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
5067 // sharing on `Arc` pointer identity, so the roots must be the
5068 // same allocations the parse produced.
5069 con_roots.extend(summands);
5070 }
5071 row_start.push(con_roots.len());
5072
5073 // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
5074 // building only when some CSE body is actually referenced from two
5075 // or more summands — otherwise the prelude comes out empty and the
5076 // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
5077 // gates the opcodes `build_multi` would panic on.
5078 // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
5079 // whole constraint block. Diagnostic only: it is how the
5080 // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
5081 // on a real model, and how a suspected hybrid-path bug is bisected
5082 // against a reference that computes the same derivatives a
5083 // different way.
5084 let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
5085 None
5086 } else if hybrid_supported(&con_roots) {
5087 let tape = HybridTape::build_multi(&con_roots);
5088 (tape.n_prelude_ops() > 0).then(|| {
5089 let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5090 let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
5091 // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
5092 // regardless of the op ratio. Diagnostic only — it is how the
5093 // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
5094 // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
5095 // models that sit below the gate.
5096 let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
5097 ConHybrid {
5098 prelude_vals: vec![0.0; tape.n_prelude_ops()],
5099 local_vals: vec![0.0; tape.max_summand_ops()],
5100 local_adj: vec![0.0; tape.max_summand_ops()],
5101 prelude_adj: vec![0.0; tape.n_prelude_ops()],
5102 use_for_jac: flat_ops as f64
5103 >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
5104 use_for_hess: force_hess
5105 || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
5106 local_vals_all: Vec::new(),
5107 local_off: Vec::new(),
5108 summand_row: Vec::new(),
5109 hess_color_summands: Vec::new(),
5110 hess_color_reach: Vec::new(),
5111 hess_color_reach_off: Vec::new(),
5112 prelude_dot: Vec::new(),
5113 hess_prelude_adj: Vec::new(),
5114 prelude_adj_dot: Vec::new(),
5115 local_dot: Vec::new(),
5116 local_adj_dot: Vec::new(),
5117 row_start,
5118 tape,
5119 }
5120 })
5121 } else {
5122 None
5123 };
5124 drop(con_roots);
5125
5126 // Hessian-of-Lagrangian sparsity: union of each tape's own
5127 // structural Hessian sparsity.
5128 // One flat `Vec`, sorted and deduped once, rather than a global
5129 // `BTreeSet` fed a single insert at a time across every summand
5130 // in the model: sort+dedup walks contiguous memory where the tree
5131 // chased a pointer and allocated a node per entry. The result is
5132 // exactly the ascending order the rest of this function wants, so
5133 // it doubles as `lower_pairs` instead of being copied into it.
5134 let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
5135 for t in &obj_tapes {
5136 tape_pairs.extend(t.hessian_sparsity());
5137 }
5138 for row in &con_tapes {
5139 for t in row {
5140 tape_pairs.extend(t.hessian_sparsity());
5141 }
5142 }
5143 tape_pairs.sort_unstable();
5144 tape_pairs.dedup();
5145
5146 // The assembled pattern is the tapes' union *plus* the quadratic
5147 // forms'. They are kept apart because the coloring only ever needs
5148 // the tape half — a quadratic block's entries are scattered
5149 // directly, not recovered from a directional product — and coloring
5150 // a dense quadratic block would put the color count back at `n` to
5151 // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
5152 let mut lower_pairs = tape_pairs.clone();
5153 if !quad.is_empty() {
5154 for f in quad
5155 .objective_form()
5156 .into_iter()
5157 .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
5158 {
5159 lower_pairs.extend(
5160 quad.lower_triangle(f)
5161 .map(|(r, c, _)| (r as usize, c as usize)),
5162 );
5163 }
5164 lower_pairs.sort_unstable();
5165 lower_pairs.dedup();
5166 }
5167
5168 // Which assembled entries a tape can write. Both sides are sorted
5169 // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
5170 // mask is left empty (meaning "all of them") when nothing was
5171 // recognized.
5172 let h_tape_mask: Vec<bool> = if quad.is_empty() {
5173 Vec::new()
5174 } else {
5175 let mut mask = vec![false; lower_pairs.len()];
5176 let mut t = 0usize;
5177 for (idx, pair) in lower_pairs.iter().enumerate() {
5178 if t < tape_pairs.len() && tape_pairs[t] == *pair {
5179 mask[idx] = true;
5180 t += 1;
5181 }
5182 }
5183 debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
5184 mask
5185 };
5186 drop(tape_pairs);
5187
5188 let mut h_irow = Vec::with_capacity(lower_pairs.len());
5189 let mut h_jcol = Vec::with_capacity(lower_pairs.len());
5190 for &(hi, lo) in &lower_pairs {
5191 h_irow.push(hi as i32);
5192 h_jcol.push(lo as i32);
5193 }
5194
5195 // Bind each form's lower-triangle entries to their index in the
5196 // assembled pattern. `lower_pairs` is sorted, so the lookup is a
5197 // binary search — done once here, never again on the hot path.
5198 if !quad.is_empty() {
5199 quad.bind_slots(|r, c| {
5200 lower_pairs
5201 .binary_search(&(r as usize, c as usize))
5202 .unwrap_or_else(|_| {
5203 unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
5204 })
5205 });
5206 }
5207
5208 // Hessian column coloring and everything keyed off it. The
5209 // chromatic number of the column-intersection graph bounds how
5210 // many directional Hessian-vector products we need per `eval_h`
5211 // call — typically O(stencil) for PDE-mesh problems.
5212 let ColorTables {
5213 var_color,
5214 n_colors,
5215 peeled_cols,
5216 seeds,
5217 decoding,
5218 obj_tape_colors,
5219 con_tape_colors,
5220 hess_color_index,
5221 } = build_color_tables(
5222 prob.n,
5223 prob.m,
5224 &lower_pairs,
5225 &h_tape_mask,
5226 &vec![false; prob.n],
5227 &obj_tapes,
5228 &con_tapes,
5229 con_hybrid.as_mut(),
5230 );
5231
5232 // Per-row Jacobian sparsity = union of tape vars plus
5233 // linear-segment vars.
5234 let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
5235 let mut jac_nnz = 0;
5236 for (i, row_tapes) in con_tapes.iter().enumerate() {
5237 let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
5238 for t in row_tapes {
5239 cols.extend(t.variables());
5240 }
5241 // A quadratic row has no tape, so its Jacobian support comes
5242 // from the form: `Hx + a` is nonzero exactly on the union of the
5243 // Hessian's rows and the folded linear part.
5244 if let Some(f) = quad.row_form(i) {
5245 cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
5246 }
5247 cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
5248 cols.sort_unstable();
5249 cols.dedup();
5250 cols.shrink_to_fit();
5251 jac_nnz += cols.len();
5252 jac_cols.push(cols);
5253 }
5254
5255 let mut max_tape_n: usize = 0;
5256 for t in &obj_tapes {
5257 max_tape_n = max_tape_n.max(t.ops.len());
5258 }
5259 for row in &con_tapes {
5260 for t in row {
5261 max_tape_n = max_tape_n.max(t.ops.len());
5262 }
5263 }
5264
5265 if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
5266 // Mean number of Hessian colors a single constraint tape touches.
5267 // It is the cost multiplier of visiting the work color-major
5268 // rather than tape-major: each (tape, color) pair needs the
5269 // tape's forward values, so a color-major walk forwards each tape
5270 // this many times instead of once.
5271 let (mut tape_color_pairs, mut n_live_tapes) = (0usize, 0usize);
5272 for row in &con_tape_colors {
5273 for cols in row {
5274 if !cols.is_empty() {
5275 tape_color_pairs += cols.len();
5276 n_live_tapes += 1;
5277 }
5278 }
5279 }
5280 let colors_per_tape = tape_color_pairs as f64 / n_live_tapes.max(1) as f64;
5281 let n_obj = obj_tapes.len();
5282 let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
5283 let total = n_obj + n_con;
5284 let mut sum_ops: usize = 0;
5285 for t in &obj_tapes {
5286 sum_ops += t.ops.len();
5287 }
5288 for row in &con_tapes {
5289 for t in row {
5290 sum_ops += t.ops.len();
5291 }
5292 }
5293 let t = total.max(1);
5294 let nnz_h = h_irow.len();
5295 let avg_decode =
5296 decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
5297 eprintln!(
5298 "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
5299 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
5300 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h} \
5301 colors_per_tape={colors_per_tape:.2}",
5302 sum_ops as f64 / t as f64,
5303 );
5304 // Flat vs shared-CSE op counts for the constraint block. The
5305 // ratio is how much duplicated CSE work `eval_g`'s hybrid path
5306 // avoids, and the ceiling on what routing the Jacobian /
5307 // Hessian through the same prelude could save.
5308 match &con_hybrid {
5309 Some(h) => {
5310 let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5311 let prelude = h.tape.n_prelude_ops();
5312 let local = h.tape.total_local_ops();
5313 eprintln!(
5314 "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
5315 local_ops={local} shared_total={} flat/shared={:.2}x \
5316 jac_gate={} hess_gate={}",
5317 prelude + local,
5318 flat as f64 / (prelude + local).max(1) as f64,
5319 if h.use_for_jac { "on" } else { "off" },
5320 if h.use_for_hess { "on" } else { "off" },
5321 );
5322 }
5323 None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
5324 }
5325 // What the constant-structure path took off the tape builder.
5326 // `forms` counts the objective too, which is why it can exceed
5327 // the row count by one.
5328 let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
5329 // How many of those forms the *parser* produced, i.e. how many
5330 // never cost an `Expr` (gh #588, Q5). The rest were recognized
5331 // from a tree that was built: a `from_expressions` model, or a
5332 // body the parser rewound because it was not degree 2.
5333 let parsed = std::iter::once(&prob.obj_nonlinear)
5334 .chain(prob.con_nonlinear.iter())
5335 .filter(|b| b.quad().is_some())
5336 .count();
5337 eprintln!(
5338 "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
5339 stored_h_entries={} colored_pairs={}/{}",
5340 quad.len(),
5341 prob.m,
5342 if quad.objective_form().is_some() {
5343 "quadratic"
5344 } else {
5345 "taped"
5346 },
5347 quad.stored_entries(),
5348 if h_tape_mask.is_empty() {
5349 lower_pairs.len()
5350 } else {
5351 h_tape_mask.iter().filter(|&&t| t).count()
5352 },
5353 lower_pairs.len(),
5354 );
5355 }
5356
5357 let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
5358
5359 let mut me = Self {
5360 prob,
5361 obj_tapes,
5362 con_tapes,
5363 con_hybrid,
5364 quad,
5365 h_tape_mask,
5366 h_irow,
5367 h_jcol,
5368 jac_cols,
5369 jac_nnz,
5370 seeds,
5371 decoding,
5372 obj_tape_colors,
5373 con_tape_colors,
5374 hess_color_index,
5375 jac_row_off: Vec::new(),
5376 #[cfg(not(target_arch = "wasm32"))]
5377 jac_scratch_pool: Vec::new(),
5378 var_color,
5379 peeled_cols,
5380 final_x: None,
5381 final_obj: 0.0,
5382 final_lambda: None,
5383 final_z_l: None,
5384 final_z_u: None,
5385 scratch_row_grad: Vec::new(),
5386 vals_scratch: vec![0.0; max_tape_n],
5387 dot_scratch: vec![0.0; max_tape_n],
5388 adj_scratch: vec![0.0; max_tape_n],
5389 adj_dot_scratch: vec![0.0; max_tape_n],
5390 compressed,
5391 hvp_live: Vec::new(),
5392 curvature_scaling: None,
5393 };
5394 me.veto_ill_conditioned_peels();
5395 Ok(me)
5396 }
5397
5398 /// Un-peel any dense column whose own pass is too ill-scaled to read
5399 /// its small entries out of, and re-color if that changes anything.
5400 ///
5401 /// `greedy_hessian_coloring` picks the peel set from structure alone,
5402 /// which is the right call for the memory and the color count but
5403 /// blind to the one thing that can go wrong: an entry recovered from
5404 /// column `d`'s pass inherits that pass's roundoff floor, about
5405 /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
5406 /// well-scaled dense column loses nothing to that — the recovered
5407 /// entries come back bit-identical to the uncompressed reference on
5408 /// every peel-firing model in the benchmark corpus but one. A column
5409 /// spanning many orders of magnitude, though, hands its small entries
5410 /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
5411 /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
5412 /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
5413 /// survives that, but the multipliers come out of the KKT system the
5414 /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
5415 /// the solve stalls short of `Optimal` on a problem it used to
5416 /// certify.
5417 ///
5418 /// Nothing structural distinguishes the two cases, so measure it: one
5419 /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
5420 /// column's exact pass sitting in `compressed`, and a column whose
5421 /// worst recovered entry would lose more than
5422 /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
5423 /// one `eval_h` — at the peeled color count, so cheap — and only for
5424 /// the ~3% of models that peel anything at all.
5425 fn veto_ill_conditioned_peels(&mut self) {
5426 if self.peeled_cols.is_empty() {
5427 return;
5428 }
5429
5430 let mut values = vec![0.0; self.h_irow.len()];
5431 let lambda = vec![1.0; self.prob.m];
5432 let x0 = self.prob.x0.clone();
5433 if !self.eval_h(
5434 Some(&x0),
5435 true,
5436 1.0,
5437 Some(&lambda),
5438 true,
5439 SparsityRequest::Values {
5440 values: &mut values,
5441 },
5442 ) {
5443 return;
5444 }
5445
5446 let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
5447 // A column whose whole pass is negligible against the Hessian as a
5448 // whole cannot move the KKT system no matter how badly its own
5449 // entries are rounded, and columns that are identically zero at
5450 // `x0` would otherwise veto on a ratio of pure noise.
5451 let h_scale = self
5452 .compressed
5453 .iter()
5454 .flat_map(|c| c.iter())
5455 .fold(0.0f64, |a, &v| a.max(v.abs()));
5456 let mut peel_veto = vec![false; self.prob.n];
5457 let mut vetoed = 0usize;
5458 for &d in &self.peeled_cols {
5459 let c = self.var_color[d as usize];
5460 if c == u32::MAX {
5461 continue;
5462 }
5463 let pass = &self.compressed[c as usize];
5464 // The floor the pass was accumulated at, against the smallest
5465 // entry actually read out of it.
5466 let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
5467 // Entries at or below the pass's own roundoff floor carry no
5468 // information to lose: `eps * scale` is the noise the pass was
5469 // accumulated at, so such an entry is already indistinguishable
5470 // from zero whether or not the column is peeled. Including them
5471 // would divide by that noise -- `orthregd` holds entries of
5472 // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
5473 // 4e-16 against an uncompressed reference.
5474 let noise = f64::EPSILON * scale;
5475 let smallest = self.decoding[c as usize]
5476 .iter()
5477 .map(|w| pass[w.row as usize].abs())
5478 .filter(|v| *v > noise)
5479 .fold(f64::INFINITY, f64::min);
5480 if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
5481 continue;
5482 }
5483 if scale <= h_scale * f64::EPSILON {
5484 continue;
5485 }
5486 let rel_err = f64::EPSILON * scale / smallest;
5487 if dbg {
5488 eprintln!(
5489 "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
5490 min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
5491 if rel_err > PEEL_MAX_REL_ERR {
5492 " VETO"
5493 } else {
5494 ""
5495 }
5496 );
5497 }
5498 if rel_err > PEEL_MAX_REL_ERR {
5499 peel_veto[d as usize] = true;
5500 vetoed += 1;
5501 }
5502 }
5503
5504 if vetoed == 0 {
5505 return;
5506 }
5507 if dbg {
5508 eprintln!(
5509 "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
5510 self.peeled_cols.len()
5511 );
5512 }
5513 self.recolor(&peel_veto);
5514 }
5515
5516 /// Rebuild the coloring and everything keyed off it, barring
5517 /// `peel_veto` from the peel set.
5518 fn recolor(&mut self, peel_veto: &[bool]) {
5519 let lower_pairs: Vec<(usize, usize)> = self
5520 .h_irow
5521 .iter()
5522 .zip(&self.h_jcol)
5523 .map(|(&i, &j)| (i as usize, j as usize))
5524 .collect();
5525 let ColorTables {
5526 var_color,
5527 n_colors,
5528 peeled_cols,
5529 seeds,
5530 decoding,
5531 obj_tape_colors,
5532 con_tape_colors,
5533 hess_color_index,
5534 } = build_color_tables(
5535 self.prob.n,
5536 self.prob.m,
5537 &lower_pairs,
5538 &self.h_tape_mask,
5539 peel_veto,
5540 &self.obj_tapes,
5541 &self.con_tapes,
5542 self.con_hybrid.as_mut(),
5543 );
5544 self.var_color = var_color;
5545 self.peeled_cols = peeled_cols;
5546 self.seeds = seeds;
5547 self.decoding = decoding;
5548 self.obj_tape_colors = obj_tape_colors;
5549 self.con_tape_colors = con_tape_colors;
5550 // Recoloring invalidates the color-major index: its entries name
5551 // colors, so a stale one would send a row's contribution to whatever
5552 // color now holds that slot. Refreshed here, not rebuilt lazily,
5553 // because nothing downstream can detect the staleness.
5554 self.hess_color_index = hess_color_index;
5555 self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
5556 }
5557
5558 pub fn final_x(&self) -> Option<&[Number]> {
5559 self.final_x.as_deref()
5560 }
5561
5562 pub fn final_obj(&self) -> Number {
5563 self.final_obj
5564 }
5565
5566 /// Converged constraint multipliers from the last solve, in original
5567 /// `.nl` row order. `None` before a solve finishes. See
5568 /// [`Self::final_x`] for the primal counterpart.
5569 pub fn final_lambda(&self) -> Option<&[Number]> {
5570 self.final_lambda.as_deref()
5571 }
5572
5573 /// Converged lower / upper bound multipliers from the last solve, in
5574 /// original `.nl` variable order and Ipopt's internal convention (both
5575 /// `>= 0`). `None` before a solve finishes.
5576 pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
5577 Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
5578 }
5579
5580 /// The parsed problem this TNLP evaluates (bounds, starting point,
5581 /// names, suffixes). Read-only; per-instance overrides go through
5582 /// [`Self::variant`].
5583 pub fn problem(&self) -> &NlProblem {
5584 &self.prob
5585 }
5586
5587 /// Opt this model in to **curvature-based** scaling (gh #703): compute
5588 /// the per-variable and per-row factors of
5589 /// [`crate::nl_scaling::curvature_scaling`] and serve them from
5590 /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
5591 /// them through the channel it already has for user factors.
5592 ///
5593 /// Returns `false` when the model is not one the scheme is defined for
5594 /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
5595 /// exists. The caller must surface that as an error rather than solving
5596 /// unscaled: an accepted scaling option that is then quietly not applied
5597 /// is exactly the gh #483 failure.
5598 ///
5599 /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
5600 /// passes over the magnitude surrogates, and nothing at all for a solve
5601 /// that never calls it.
5602 pub fn enable_curvature_scaling(&mut self) -> bool {
5603 match crate::nl_scaling::curvature_scaling(&self.prob) {
5604 Some(sc) => {
5605 self.curvature_scaling = Some(sc);
5606 true
5607 }
5608 None => false,
5609 }
5610 }
5611
5612 /// Whether [`Self::enable_curvature_scaling`] has been called and
5613 /// succeeded.
5614 pub fn curvature_scaling_enabled(&self) -> bool {
5615 self.curvature_scaling.is_some()
5616 }
5617
5618 /// Whether the enabled curvature scaling actually read any curvature —
5619 /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
5620 /// scaling is not enabled, and `false` for a degree-≤2 model whose every
5621 /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
5622 /// equilibration of `[A b]`.
5623 pub fn curvature_scaling_read_curvature(&self) -> bool {
5624 self.curvature_scaling
5625 .as_ref()
5626 .is_some_and(|sc| sc.quadratic)
5627 }
5628
5629 /// Is constraint row `i` evaluated from a constant quadratic form
5630 /// rather than from an AD tape (gh #588, Q4)?
5631 ///
5632 /// Structural, so it answers before any evaluation. Exposed for the
5633 /// differential test, which has to know which models exercise the fast
5634 /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
5635 pub fn quadratic_row(&self, i: usize) -> bool {
5636 self.quad.row_form(i).is_some()
5637 }
5638
5639 /// As [`Self::quadratic_row`], for the objective.
5640 pub fn quadratic_objective(&self) -> bool {
5641 self.quad.objective_form().is_some()
5642 }
5643
5644 /// Structural set of variables that appear in *some* nonlinear part —
5645 /// the union of `collect_vars` over the objective and every constraint
5646 /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5647 /// list below so the two can never disagree.
5648 fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5649 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5650 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5651 for row in &self.prob.con_nonlinear {
5652 row.collect_vars(&mut nonlinear);
5653 }
5654 nonlinear
5655 }
5656
5657 /// The nonlinear-variable list published through the TNLP contract
5658 /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5659 /// ascending, in the C index style [`NlTnlp`] reports.
5660 ///
5661 /// The contract's asymmetry decides how this is computed. A consumer
5662 /// treats every variable *absent* from the list as linear — Ipopt's
5663 /// limited-memory Hessian skips the quasi-Newton update in that
5664 /// subspace — so naming too few variables is a wrong answer, while
5665 /// naming too many merely costs work. Hence:
5666 ///
5667 /// * When the `.nl` header says **every** variable is nonlinear, publish
5668 /// that and skip the walk. This is the maximally conservative answer,
5669 /// so it is sound whatever the header's provenance, and it is the
5670 /// common case for the models this matters on (`eigena2`: 110 of 110).
5671 /// * Otherwise walk the trees. The header's prefix
5672 /// (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5673 /// answer, but it would be one that *trusts* the writer to have
5674 /// ordered the variables as the format requires, and the walk is
5675 /// already paid once per solve by `get_variables_linearity`. The
5676 /// walk is also the only option for a model built through
5677 /// [`NlProblem::from_expressions`], which has no header at all.
5678 ///
5679 /// The two disagree only in the safe direction, which
5680 /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5681 /// fixture corpus: the walked set always sits inside the header's
5682 /// prefix, because `parse_nl_text` folds constant `C` bodies away
5683 /// (`gh #492`) and AMPL's own census predates that fold.
5684 fn nonlinear_variables(&self) -> Vec<Index> {
5685 if let Some(c) = self.prob.nl_counts
5686 && c.nonlinear_vars() >= self.prob.n
5687 {
5688 return (0..self.prob.n as Index).collect();
5689 }
5690 self.nonlinear_var_set()
5691 .into_iter()
5692 .map(|i| i as Index)
5693 .collect()
5694 }
5695
5696 /// Mutable access to that same problem, for a caller that owns this
5697 /// TNLP outright.
5698 ///
5699 /// The tapes were built from the expressions in [`Self::problem`] and
5700 /// are not rebuilt, so editing an expression here does **not** change
5701 /// what this TNLP evaluates. It exists for teardown: the Python
5702 /// binding takes the expression trees out through here so a deeply
5703 /// nested one is dropped on a stack chosen for it rather than
5704 /// recursively on whatever thread collected the object (pounce#472).
5705 pub fn problem_mut(&mut self) -> &mut NlProblem {
5706 &mut self.prob
5707 }
5708
5709 /// Hessian-vector product of the Lagrangian:
5710 /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5711 ///
5712 /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5713 /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5714 /// compressed columns into the sparse lower triangle; here the seed is
5715 /// the caller's `v` directly, so it is a single forward-over-reverse
5716 /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5717 /// chromatic number. That is what makes it usable on models where
5718 /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5719 /// truncated-CG step only ever needs `∇²L · v`.
5720 ///
5721 /// Sign convention matches `eval_h` and the rest of this evaluator: a
5722 /// `maximize` model's objective is negated so the returned operator is
5723 /// the one that minimizing solves. `lambda` is `None` for the objective
5724 /// block alone.
5725 ///
5726 /// `out` is overwritten (not accumulated into). Errors on any length
5727 /// mismatch rather than panicking, since the Python binding hands this
5728 /// arbitrary user arrays.
5729 pub fn hessian_vector_product(
5730 &mut self,
5731 x: &[Number],
5732 v: &[Number],
5733 obj_factor: Number,
5734 lambda: Option<&[Number]>,
5735 out: &mut [Number],
5736 ) -> Result<(), String> {
5737 self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5738 }
5739
5740 /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5741 /// once, `out[:, c] = ∇²L · v[:, c]`.
5742 ///
5743 /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5744 /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5745 ///
5746 /// Worth having as its own entry point rather than a loop over the
5747 /// single-vector call: the forward sweep depends only on `x`, so a block
5748 /// runs it *once per tape* and reuses `vals` across all `k` directions,
5749 /// where `k` separate calls would redo it `k` times. Only the
5750 /// forward-tangent + reverse-over-tangent passes are per-direction. That
5751 /// is the shape a block-Krylov solve, a directional-derivative probe, or
5752 /// a densify-the-Hessian loop wants.
5753 ///
5754 /// An all-zero direction is skipped, so passing a sparse block whose
5755 /// columns are mostly empty costs only the columns that carry signal.
5756 /// (The sparsity that dominates is the model's own: each tape touches
5757 /// only its own variables, and `hessian_directional` is O(tape ops), not
5758 /// O(n).)
5759 pub fn hessian_vector_products(
5760 &mut self,
5761 x: &[Number],
5762 v: &[Number],
5763 k: usize,
5764 obj_factor: Number,
5765 lambda: Option<&[Number]>,
5766 out: &mut [Number],
5767 ) -> Result<(), String> {
5768 let (n, m) = (self.prob.n, self.prob.m);
5769 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5770 if got == want {
5771 Ok(())
5772 } else {
5773 Err(format!(
5774 "hessian_vector_product: {name} has length {got}, expected {want}"
5775 ))
5776 }
5777 };
5778 check("x", x.len(), n)?;
5779 check("v", v.len(), n * k)?;
5780 check("out", out.len(), n * k)?;
5781 if let Some(lam) = lambda {
5782 check("lambda", lam.len(), m)?;
5783 }
5784
5785 out.fill(0.0);
5786 if k == 0 || n == 0 {
5787 return Ok(());
5788 }
5789
5790 // Which directions carry signal. Computed once, not per (tape,
5791 // direction) pair — with many small summand tapes the scan would
5792 // otherwise dominate the work it is meant to save. Reuses the
5793 // persistent mask so a Krylov loop allocates nothing per iteration.
5794 self.hvp_live.clear();
5795 self.hvp_live
5796 .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5797 if !self.hvp_live.iter().any(|&l| l) {
5798 return Ok(());
5799 }
5800
5801 let obj_seed = if self.prob.minimize {
5802 obj_factor
5803 } else {
5804 -obj_factor
5805 };
5806 // The constant blocks. `H · v` for a quadratic form is a matvec
5807 // against stored values, so unlike a tape it needs no forward sweep
5808 // and no `x` at all. Easy to forget — a matrix-free solve that
5809 // silently dropped the quadratic part of `∇²L` would still converge,
5810 // just to a different point by a different route, which is the
5811 // failure mode this series is most exposed to.
5812 if !self.quad.is_empty() {
5813 if obj_seed != 0.0 {
5814 if let Some(f) = self.quad.objective_form() {
5815 for (c, out_col) in out.chunks_mut(n).enumerate() {
5816 if self.hvp_live[c] {
5817 self.quad.add_hessian_vector(
5818 f,
5819 &v[c * n..(c + 1) * n],
5820 obj_seed,
5821 out_col,
5822 );
5823 }
5824 }
5825 }
5826 }
5827 if let Some(lam) = lambda {
5828 for (i, &w) in lam.iter().enumerate() {
5829 if w == 0.0 {
5830 continue;
5831 }
5832 let Some(f) = self.quad.row_form(i) else {
5833 continue;
5834 };
5835 for (c, out_col) in out.chunks_mut(n).enumerate() {
5836 if self.hvp_live[c] {
5837 self.quad
5838 .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5839 }
5840 }
5841 }
5842 }
5843 }
5844
5845 if obj_seed != 0.0 {
5846 for t in &self.obj_tapes {
5847 if t.ops.is_empty() {
5848 continue;
5849 }
5850 // Once per tape, not once per direction — the whole point
5851 // of the block form.
5852 t.forward_into(x, &mut self.vals_scratch);
5853 for (c, out_col) in out.chunks_mut(n).enumerate() {
5854 if !self.hvp_live[c] {
5855 continue;
5856 }
5857 t.hessian_directional(
5858 &self.vals_scratch,
5859 &v[c * n..(c + 1) * n],
5860 obj_seed,
5861 out_col,
5862 &mut self.dot_scratch,
5863 &mut self.adj_scratch,
5864 &mut self.adj_dot_scratch,
5865 );
5866 }
5867 }
5868 }
5869
5870 if let Some(lam) = lambda {
5871 // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5872 // range for every k.
5873 for (i, &w) in lam.iter().enumerate() {
5874 if w == 0.0 {
5875 continue;
5876 }
5877 for t in &self.con_tapes[i] {
5878 if t.ops.is_empty() {
5879 continue;
5880 }
5881 t.forward_into(x, &mut self.vals_scratch);
5882 for (c, out_col) in out.chunks_mut(n).enumerate() {
5883 if !self.hvp_live[c] {
5884 continue;
5885 }
5886 t.hessian_directional(
5887 &self.vals_scratch,
5888 &v[c * n..(c + 1) * n],
5889 w,
5890 out_col,
5891 &mut self.dot_scratch,
5892 &mut self.adj_scratch,
5893 &mut self.adj_dot_scratch,
5894 );
5895 }
5896 }
5897 }
5898 }
5899
5900 Ok(())
5901 }
5902
5903 /// Clone this TNLP with per-instance overrides applied — the
5904 /// "one structure, many bound / starting-point variations" case of
5905 /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5906 /// coloring are reused via `Clone` (they depend only on the model
5907 /// structure, which a variation cannot change); only the values in
5908 /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5909 /// are replaced. Any stale `final_x` from a previous solve of
5910 /// `self` is cleared on the clone.
5911 ///
5912 /// Errors when an override's length does not match the model
5913 /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5914 pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5915 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5916 if got == want {
5917 Ok(())
5918 } else {
5919 Err(format!(
5920 "NlVariation.{name} has length {got}, expected {want}"
5921 ))
5922 }
5923 };
5924 let mut out = self.clone();
5925 out.final_x = None;
5926 out.final_obj = 0.0;
5927 out.final_lambda = None;
5928 out.final_z_l = None;
5929 out.final_z_u = None;
5930 if let Some(x0) = &v.x0 {
5931 check("x0", x0.len(), self.prob.n)?;
5932 out.prob.x0.clone_from(x0);
5933 }
5934 if let Some(x_l) = &v.x_l {
5935 check("x_l", x_l.len(), self.prob.n)?;
5936 out.prob.x_l.clone_from(x_l);
5937 }
5938 if let Some(x_u) = &v.x_u {
5939 check("x_u", x_u.len(), self.prob.n)?;
5940 out.prob.x_u.clone_from(x_u);
5941 }
5942 if let Some(g_l) = &v.g_l {
5943 check("g_l", g_l.len(), self.prob.m)?;
5944 out.prob.g_l.clone_from(g_l);
5945 }
5946 if let Some(g_u) = &v.g_u {
5947 check("g_u", g_u.len(), self.prob.m)?;
5948 out.prob.g_u.clone_from(g_u);
5949 }
5950 Ok(out)
5951 }
5952
5953 /// Build one [`NlTnlp`] per variation, sharing this instance's
5954 /// structure (see [`Self::variant`]). Returns instances in input
5955 /// order; errors on the first length-mismatched variation.
5956 pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5957 vs.iter().map(|v| self.variant(v)).collect()
5958 }
5959}
5960
5961/// Per-instance overrides for building a family of related NLP
5962/// instances from one parsed `.nl` model (pounce#126): same structure
5963/// and tapes, different starting point and/or bounds — parametric
5964/// sweeps, multi-start, or branch-and-bound node relaxations where
5965/// each node only tightens variable bounds. `None` keeps the base
5966/// model's value.
5967#[derive(Debug, Clone, Default)]
5968pub struct NlVariation {
5969 pub x0: Option<Vec<Number>>,
5970 pub x_l: Option<Vec<Number>>,
5971 pub x_u: Option<Vec<Number>>,
5972 pub g_l: Option<Vec<Number>>,
5973 pub g_u: Option<Vec<Number>>,
5974}
5975
5976impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5977 /// Per-`.nl`-row constraint expression tape, with the linear
5978 /// part folded in. Returns `None` for constraints that contribute
5979 /// neither a nonlinear expression nor any linear coefficients
5980 /// (so FBBT skips them — there's nothing to tighten).
5981 fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5982 if i >= self.prob.con_nonlinear.len() {
5983 return None;
5984 }
5985 let nonlinear = self.prob.con_expr(i);
5986 let linear = self
5987 .prob
5988 .con_linear
5989 .get(i)
5990 .map(|v| v.as_slice())
5991 .unwrap_or(&[]);
5992 // FBBT needs the tree, so a recognized body is rebuilt here. It is
5993 // rebuilt once per row per presolve pass and dropped again, so the
5994 // DAG never all exists at once — which is the property the phase is
5995 // actually about. Translating the stored coefficients instead would
5996 // propagate bounds through a different association and change the
5997 // tightening; that is not a trade this phase makes.
5998 crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
5999 }
6000
6001 /// Variable name from the sibling `.col` file, if one was loaded.
6002 /// Index is original `.nl` column order.
6003 fn variable_name(&self, i: usize) -> Option<&str> {
6004 self.prob.var_names.get(i).map(String::as_str)
6005 }
6006
6007 /// Constraint name from the sibling `.row` file, if one was loaded.
6008 /// Index is original `.nl` row order.
6009 fn constraint_name(&self, i: usize) -> Option<&str> {
6010 self.prob.con_names.get(i).map(String::as_str)
6011 }
6012}
6013
6014/// Per-worker scratch for the parallel Hessian walk. Four buffers of
6015/// `max_tape_n` — the largest tape's node count, tens of entries on the models
6016/// this path is for — so a worker's copy costs about a kilobyte.
6017#[cfg(not(target_arch = "wasm32"))]
6018struct HessScratch {
6019 vals: Vec<f64>,
6020 dot: Vec<f64>,
6021 adj: Vec<f64>,
6022 adj_dot: Vec<f64>,
6023}
6024
6025/// Per-worker scratch for the parallel Jacobian walk: one gradient row of
6026/// length `n` plus the two small tape arenas `gradient_seed_into` fills.
6027#[cfg(not(target_arch = "wasm32"))]
6028#[derive(Debug, Clone)]
6029struct JacScratch {
6030 row_grad: Vec<f64>,
6031 vals: Vec<f64>,
6032 adj: Vec<f64>,
6033}
6034
6035#[cfg(not(target_arch = "wasm32"))]
6036impl JacScratch {
6037 fn new(n: usize, max_tape_n: usize) -> Self {
6038 Self {
6039 row_grad: vec![0.0; n],
6040 vals: vec![0.0; max_tape_n],
6041 adj: vec![0.0; max_tape_n],
6042 }
6043 }
6044}
6045
6046#[cfg(not(target_arch = "wasm32"))]
6047impl HessScratch {
6048 fn new(n: usize) -> Self {
6049 Self {
6050 vals: vec![0.0; n],
6051 dot: vec![0.0; n],
6052 adj: vec![0.0; n],
6053 adj_dot: vec![0.0; n],
6054 }
6055 }
6056}
6057
6058impl NlTnlp {
6059 /// Smallest (row, tape) × color work list that earns a parallel walk.
6060 /// Below it the rayon dispatch costs more than the sweeps it splits, and
6061 /// the serial tape-major walk is also cheaper per unit work because it
6062 /// forwards each tape once rather than once per color.
6063 ///
6064 /// Measured on a banded synthetic (`sin(x_i·x_{i+1}) + x_{i+1}²·x_{i+2}`
6065 /// per row, the model in `parallel_hessian_matches_serial.rs`), serial ->
6066 /// parallel with the gate forced open:
6067 ///
6068 /// | pairs | serial | parallel | ratio |
6069 /// |---|---|---|---|
6070 /// | 400 | 0.028 ms | 0.036 ms | 0.79x |
6071 /// | 1 200 | 0.073 ms | 0.066 ms | 1.11x |
6072 /// | 4 000 | 0.179 ms | 0.112 ms | 1.60x |
6073 /// | 12 000 | 0.344 ms | 0.203 ms | 1.69x |
6074 ///
6075 /// The crossover sits between 400 and 1 200, hence this value.
6076 ///
6077 /// **What that table does not say is how big the win gets.** That model
6078 /// has three Hessian colors, so it has three tasks to hand out however
6079 /// many cores are free, and 1.7x is near its ceiling. The parallelism
6080 /// available here *is* the color count: an AC-SCOPF model with 28 colors
6081 /// runs the same walk 4.9x faster (108.3 ms -> 22.2 ms at 54 859
6082 /// variables, 421.0 ms -> 89.0 ms at 209 755). Gating on pair count alone
6083 /// is therefore conservative in the safe direction — at equal pairs, more
6084 /// colors can only help.
6085 const HESS_PAR_MIN_PAIRS: usize = 1024;
6086
6087 /// Whether the flat Hessian walk runs color-major and in parallel.
6088 ///
6089 /// `POUNCE_NL_PARALLEL_EVAL` overrides the size gate: `0` forces the
6090 /// serial walk, `1` forces the parallel one whatever the size. The two
6091 /// produce bit-identical values, so this is a lever for measurement and
6092 /// for pinning a suspected parallelism bug, not a correctness switch —
6093 /// and `1` is how the threshold below was measured, since the gate would
6094 /// otherwise hide the small end of the curve.
6095 #[cfg(not(target_arch = "wasm32"))]
6096 fn hess_flat_parallel(&self) -> bool {
6097 // More than one color is a hard requirement, not a heuristic: with one
6098 // color there is one task and the inversion is pure overhead.
6099 let multi_color = self.compressed.len() > 1;
6100 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6101 Ok("0") | Ok("no") | Ok("false") => false,
6102 Ok("1") | Ok("yes") | Ok("true") => multi_color,
6103 _ => multi_color && self.hess_color_index.con_row.len() >= Self::HESS_PAR_MIN_PAIRS,
6104 }
6105 }
6106
6107 /// Smallest Jacobian nonzero count that earns a parallel walk. Same shape
6108 /// of trade as [`Self::HESS_PAR_MIN_PAIRS`], but a cheaper one to cross:
6109 /// the row walk is *not* reordered or duplicated, so the only cost is the
6110 /// dispatch and one length-`n` scratch row per worker.
6111 #[cfg(not(target_arch = "wasm32"))]
6112 const JAC_PAR_MIN_NNZ: usize = 4096;
6113
6114 /// Smallest row count that earns a parallel constraint evaluation. This is
6115 /// the cheapest of the three walks per row — one forward sweep, no
6116 /// gradient, no directional derivative — so the dispatch is a larger share
6117 /// of it and the threshold is the highest of the three.
6118 ///
6119 /// Measured on the banded synthetic, serial -> parallel with the gate
6120 /// forced open: 500 rows 0.34x, 2 000 0.84x, 5 000 1.07x, 8 000 1.53x,
6121 /// 20 000 3.19x, 60 000 4.97x, 200 000 5.78x. The crossover is near 5 000,
6122 /// so the gate sits just under it. Unlike the Hessian, this walk has no
6123 /// color ceiling — every row is its own task — which is why it keeps
6124 /// climbing to ~6x where the Hessian's 3-color model stalls at 1.7x.
6125 #[cfg(not(target_arch = "wasm32"))]
6126 const EVAL_G_PAR_MIN_ROWS: usize = 4096;
6127
6128 /// Whether `eval_g` runs in parallel over rows. Shares
6129 /// `POUNCE_NL_PARALLEL_EVAL` with the other two walks.
6130 #[cfg(not(target_arch = "wasm32"))]
6131 fn eval_g_parallel(&self) -> bool {
6132 // The shared-CSE arm returns earlier; this gate only ever sees the
6133 // flat path.
6134 let big = self.prob.m >= Self::EVAL_G_PAR_MIN_ROWS;
6135 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6136 Ok("0") | Ok("no") | Ok("false") => false,
6137 Ok("1") | Ok("yes") | Ok("true") => true,
6138 _ => big,
6139 }
6140 }
6141
6142 /// Whether the flat Jacobian walk runs in parallel over row groups.
6143 /// Shares `POUNCE_NL_PARALLEL_EVAL` with the Hessian.
6144 #[cfg(not(target_arch = "wasm32"))]
6145 fn jac_flat_parallel(&self) -> bool {
6146 let big = self.jac_nnz >= Self::JAC_PAR_MIN_NNZ;
6147 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
6148 Ok("0") | Ok("no") | Ok("false") => false,
6149 Ok("1") | Ok("yes") | Ok("true") => true,
6150 _ => big,
6151 }
6152 }
6153
6154 /// The flat Jacobian, walked in parallel over contiguous groups of rows.
6155 ///
6156 /// Unlike the Hessian this needs no inversion: rows are already
6157 /// independent — each zeroes only its own columns of the gradient scratch,
6158 /// accumulates into them, and writes a contiguous run of `values`. So the
6159 /// work is split by *rows*, each group keeping the serial order inside
6160 /// itself, and every row computes exactly what it computed serially. The
6161 /// results are bit-identical for the stronger reason that nothing was
6162 /// reordered at all.
6163 ///
6164 /// Two things are per-worker rather than per-call: the length-`n` gradient
6165 /// row (the reason this is not simply `par_iter`, since `n` can be tens of
6166 /// thousands and allocating one per row would dwarf the work) and the two
6167 /// small tape arenas. They are pooled on `self` and reused across
6168 /// evaluations.
6169 #[cfg(not(target_arch = "wasm32"))]
6170 fn jac_flat_rows_parallel(&mut self, x_arg: Option<&[Number]>, values: &mut [Number]) {
6171 use rayon::prelude::*;
6172 let n = self.prob.n;
6173 let m = self.prob.m;
6174 let groups = rayon::current_num_threads().clamp(1, m.max(1));
6175 let max_tape_n = self.vals_scratch.len();
6176
6177 // Row -> offset of its run in `values`, so a group can be hnaded one
6178 // contiguous slice. Rebuilt only when the shape changes.
6179 if self.jac_row_off.len() != m + 1 {
6180 self.jac_row_off = Vec::with_capacity(m + 1);
6181 let mut acc = 0u32;
6182 for cols in &self.jac_cols {
6183 self.jac_row_off.push(acc);
6184 acc += cols.len() as u32;
6185 }
6186 self.jac_row_off.push(acc);
6187 }
6188 if self.jac_scratch_pool.len() != groups {
6189 self.jac_scratch_pool = (0..groups)
6190 .map(|_| JacScratch::new(n, max_tape_n))
6191 .collect();
6192 }
6193
6194 // Equal-row groups. Rows differ in width, so this is not a perfect
6195 // balance; rayon is given more groups than threads only when that is
6196 // free, and the measurement below is taken with this split.
6197 let per = m.div_ceil(groups.max(1));
6198 let Self {
6199 prob,
6200 con_tapes,
6201 quad,
6202 jac_cols,
6203 jac_row_off,
6204 jac_scratch_pool,
6205 ..
6206 } = self;
6207 let xs = x_arg.unwrap_or(&prob.x0);
6208
6209 // Hand each group its own contiguous run of `values`.
6210 let mut rest: &mut [Number] = values;
6211 let mut slices: Vec<&mut [Number]> = Vec::with_capacity(groups);
6212 for g in 0..groups {
6213 let end_row = ((g + 1) * per).min(m);
6214 let start_row = (g * per).min(m);
6215 let len = (jac_row_off[end_row] - jac_row_off[start_row]) as usize;
6216 let (head, tail) = rest.split_at_mut(len);
6217 slices.push(head);
6218 rest = tail;
6219 }
6220
6221 jac_scratch_pool
6222 .par_iter_mut()
6223 .zip(slices.into_par_iter())
6224 .enumerate()
6225 .for_each(|(g, (sc, out))| {
6226 let (lo, hi) = ((g * per).min(m), ((g + 1) * per).min(m));
6227 let JacScratch {
6228 row_grad,
6229 vals,
6230 adj,
6231 } = sc;
6232 let mut k = 0usize;
6233 for i in lo..hi {
6234 for &j in &jac_cols[i] {
6235 row_grad[j] = 0.0;
6236 }
6237 for t in &con_tapes[i] {
6238 t.gradient_seed_into(xs, 1.0, row_grad, vals, adj);
6239 }
6240 if let Some(f) = quad.row_form(i) {
6241 quad.add_gradient(f, xs, 1.0, row_grad);
6242 }
6243 for &(v, c) in &prob.con_linear[i] {
6244 row_grad[v] += c;
6245 }
6246 for &j in &jac_cols[i] {
6247 out[k] = row_grad[j];
6248 k += 1;
6249 }
6250 }
6251 });
6252 }
6253
6254 /// The flat Hessian's constraint block, walked color-major with one rayon
6255 /// task per color.
6256 ///
6257 /// Each task owns its color's `compressed[c]` outright, so nothing is
6258 /// shared mutably and no reduction is needed. Within a color the pairs are
6259 /// visited in ascending (row, tape) order — the order the serial walk
6260 /// contributes them in — so the sums are identical term by term and the
6261 /// two paths agree bit-for-bit, which
6262 /// `parallel_matches_serial_bit_for_bit` pins.
6263 ///
6264 /// The cost of the inversion is forwarding a tape once per color it
6265 /// touches instead of once for all of them (2.9 colors per tape measured
6266 /// on AC-SCOPF). That is paid back by the parallelism and only above
6267 /// `HESS_PAR_MIN_PAIRS`.
6268 #[cfg(not(target_arch = "wasm32"))]
6269 fn hess_flat_colors_parallel(&mut self, x_arg: Option<&[Number]>, lam: &[Number]) {
6270 use rayon::prelude::*;
6271 let Self {
6272 compressed,
6273 seeds,
6274 con_tapes,
6275 hess_color_index: idx,
6276 vals_scratch,
6277 prob,
6278 ..
6279 } = self;
6280 let x = x_arg.unwrap_or(&prob.x0);
6281 let max_tape_n = vals_scratch.len();
6282 compressed.par_iter_mut().enumerate().for_each_init(
6283 || HessScratch::new(max_tape_n),
6284 |sc, (c, out)| {
6285 let (rows, tapes) = idx.con_pairs(c);
6286 let seed = &seeds[c];
6287 let HessScratch {
6288 vals,
6289 dot,
6290 adj,
6291 adj_dot,
6292 } = sc;
6293 for (&k, &ti) in rows.iter().zip(tapes.iter()) {
6294 let w = lam[k as usize];
6295 if w == 0.0 {
6296 continue;
6297 }
6298 let t = &con_tapes[k as usize][ti as usize];
6299 if t.ops.is_empty() {
6300 continue;
6301 }
6302 t.forward_into(x, vals);
6303 t.hessian_directional(vals, seed, w, out, dot, adj, adj_dot);
6304 }
6305 },
6306 );
6307 }
6308}
6309
6310impl TNLP for NlTnlp {
6311 /// An `.nl` model carries no callback: the CLI and the browser build
6312 /// read the iteration log, not `IterStats` (gh#965).
6313 fn wants_intermediate_callback(&self) -> bool {
6314 false
6315 }
6316
6317 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
6318 Some(NlpInfo {
6319 n: self.prob.n as Index,
6320 m: self.prob.m as Index,
6321 nnz_jac_g: self.jac_nnz as Index,
6322 nnz_h_lag: self.h_irow.len() as Index,
6323 index_style: IndexStyle::C,
6324 })
6325 }
6326
6327 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
6328 b.x_l.copy_from_slice(&self.prob.x_l);
6329 b.x_u.copy_from_slice(&self.prob.x_u);
6330 if !self.prob.g_l.is_empty() {
6331 b.g_l.copy_from_slice(&self.prob.g_l);
6332 b.g_u.copy_from_slice(&self.prob.g_u);
6333 }
6334 true
6335 }
6336
6337 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
6338 sp.x.copy_from_slice(&self.prob.x0);
6339 // The `.nl` `d` segment supplies initial constraint multipliers
6340 // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
6341 // the engine when `warm_start_init_point yes` — by handing them
6342 // back; `OrigIpoptNlp::get_starting_point` then compresses them into
6343 // the algorithm-side y_c / y_d. Without this the warm start silently
6344 // began from zero multipliers, discarding the parsed duals. (Code
6345 // review 2026-06 item M19.)
6346 if sp.init_lambda {
6347 sp.lambda.copy_from_slice(&self.prob.lambda0);
6348 }
6349 // Bound multipliers come from the `ipopt_zL_in` / `ipopt_zU_in`
6350 // variable suffixes, as in Ipopt's `AmplTNLP::get_starting_point`:
6351 // `z_L = sigma * zL_in`, `z_U = -sigma * zU_in` (sigma = -1 for a
6352 // maximize model) — the inverse of the CLI's `ipopt_zL_out` /
6353 // `ipopt_zU_out`, so a `.sol` result fed back in round-trips. A
6354 // suffix the file does not carry leaves that block to the engine's
6355 // defaults, as before.
6356 if sp.init_z {
6357 let sigma = if self.prob.minimize { 1.0 } else { -1.0 };
6358 let sfx = &self.prob.suffixes;
6359 for (name, out, sign) in [
6360 ("ipopt_zL_in", &mut *sp.z_l, sigma),
6361 ("ipopt_zU_in", &mut *sp.z_u, -sigma),
6362 ] {
6363 // A writer may emit an all-integer suffix as integer-kind.
6364 let vals: Option<Vec<Number>> = sfx.var_real.get(name).cloned().or_else(|| {
6365 sfx.var_int
6366 .get(name)
6367 .map(|v| v.iter().map(|&x| x as Number).collect())
6368 });
6369 if let Some(v) = vals.filter(|v| v.len() == out.len()) {
6370 for (o, x) in out.iter_mut().zip(v) {
6371 *o = sign * x;
6372 }
6373 }
6374 }
6375 }
6376 true
6377 }
6378
6379 /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
6380 /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
6381 /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
6382 /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
6383 /// writes into. Before gh#483 nothing implemented this callback for
6384 /// `.nl` input, so a tagged model reached the solver with the option
6385 /// accepted and *no* scaling applied, silently.
6386 ///
6387 /// Returns `false` (engine falls back to no scaling) when the file
6388 /// declares no `scaling_factor` suffix at all — the same "user
6389 /// supplied nothing" answer as the default `TNLP` impl.
6390 ///
6391 /// AMPL suffix vectors default to **0** for components the model did
6392 /// not tag, and 0 is not a usable scale factor. A zero entry is
6393 /// therefore read as "not tagged" and becomes 1.0, which is what
6394 /// "unlisted components are unscaled" means. Per-variable factors
6395 /// are passed straight through: `OrigIpoptNlp` does not model them
6396 /// and refuses the solve with a message rather than dropping them.
6397 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
6398 const NAME: &str = "scaling_factor";
6399 let sfx = &self.prob.suffixes;
6400 let obj = sfx.obj_real.get(NAME);
6401 let var = sfx.var_real.get(NAME);
6402 let con = sfx.con_real.get(NAME);
6403 let computed = self.curvature_scaling.as_ref();
6404 if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
6405 return false;
6406 }
6407 // Curvature-based factors (gh #703) are the *base*; a
6408 // `scaling_factor` suffix the model actually carries overrides
6409 // them component by component, because an explicit factor from the
6410 // modeller beats one inferred from the coefficients. A model with
6411 // no suffixes is the ordinary case and gets the computed vectors
6412 // whole.
6413 if let Some(sc) = computed {
6414 // The length guards are a `copy_from_slice` panic guard, not a
6415 // policy: `curvature_scaling` sizes both vectors from the same
6416 // `NlProblem` this callback is answering for, so a mismatch is a
6417 // bug upstream, not a model the scheme declines. Declining is the
6418 // *worst* available response to it — `use_*_scaling` stays false,
6419 // the engine reads that as "user supplied nothing", and the run
6420 // proceeds unscaled with the option accepted, which is gh #483
6421 // again. Assert it in debug builds so a mismatch is found here,
6422 // where it is one line, instead of as a slow solve later.
6423 debug_assert_eq!(
6424 sc.x.len(),
6425 req.x_scaling.len(),
6426 "curvature x-scaling sized {} for a {}-variable request",
6427 sc.x.len(),
6428 req.x_scaling.len()
6429 );
6430 debug_assert_eq!(
6431 sc.g.len(),
6432 req.g_scaling.len(),
6433 "curvature g-scaling sized {} for a {}-row request",
6434 sc.g.len(),
6435 req.g_scaling.len()
6436 );
6437 if sc.x.len() == req.x_scaling.len() {
6438 req.x_scaling.copy_from_slice(&sc.x);
6439 *req.use_x_scaling = true;
6440 }
6441 if sc.g.len() == req.g_scaling.len() {
6442 req.g_scaling.copy_from_slice(&sc.g);
6443 *req.use_g_scaling = true;
6444 }
6445 }
6446 // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
6447 // are parsed and ignored), so its entry is the objective scale.
6448 *req.obj_scaling = obj
6449 .and_then(|v| v.first().copied())
6450 .filter(|&s| s != 0.0)
6451 .unwrap_or(1.0);
6452 // A zero entry is AMPL's "untagged" default, not a scale factor.
6453 // An untagged component therefore falls back to the base: the
6454 // curvature factor when one was computed, and an explicit 1.0
6455 // otherwise — explicit because the callback's contract is to fill
6456 // the buffer, not to assume the caller pre-filled it with ones.
6457 if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
6458 for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
6459 if s != 0.0 {
6460 *slot = s;
6461 } else if computed.is_none() {
6462 *slot = 1.0;
6463 }
6464 }
6465 *req.use_x_scaling = true;
6466 } else if computed.is_none() {
6467 *req.use_x_scaling = false;
6468 }
6469 if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
6470 for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
6471 if s != 0.0 {
6472 *slot = s;
6473 } else if computed.is_none() {
6474 *slot = 1.0;
6475 }
6476 }
6477 *req.use_g_scaling = true;
6478 } else if computed.is_none() {
6479 *req.use_g_scaling = false;
6480 }
6481 true
6482 }
6483
6484 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
6485 // Reuse the shared forward-value arena (sized to `max_tape_n`) so
6486 // each summand sweep allocates nothing — see `Tape::eval_into`.
6487 let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
6488 let mut nl: Number = 0.0;
6489 for t in obj_tapes {
6490 nl += t.eval_into(x, vals);
6491 }
6492 // A recognized objective has no tapes, so the loop above added
6493 // nothing and the form supplies the whole nonlinear part — including
6494 // the linear and constant terms AMPL folded into that tree, which is
6495 // why they are *not* also in `obj_linear` / `obj_constant`.
6496 if let Some(f) = self.quad.objective_form() {
6497 nl += self.quad.value(f, x);
6498 }
6499 let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
6500 let v = self.prob.obj_constant + nl + lin;
6501 let signed = if self.prob.minimize { v } else { -v };
6502 Some(signed)
6503 }
6504
6505 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
6506 grad.fill(0.0);
6507 // Reuse the forward-value / adjoint scratch arenas (sized to
6508 // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
6509 // nothing — see `Tape::gradient_seed_into` (M18).
6510 for t in &self.obj_tapes {
6511 t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
6512 }
6513 if let Some(f) = self.quad.objective_form() {
6514 self.quad.add_gradient(f, x, 1.0, grad);
6515 }
6516 for (i, c) in &self.prob.obj_linear {
6517 grad[*i] += c;
6518 }
6519 if !self.prob.minimize {
6520 for g in grad.iter_mut() {
6521 *g = -*g;
6522 }
6523 }
6524 true
6525 }
6526
6527 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
6528 // Constraint values are the line search's inner loop: on a
6529 // constraint-heavy model (m >> n) this runs ~10x per iteration over
6530 // every summand tape in the problem. Reuse the shared forward-value
6531 // arena so the sweep allocates nothing — the per-summand `Vec` the
6532 // allocating `Tape::eval` used to build was ~20% of `eval_g` on
6533 // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
6534 // `Tape::eval_into`.
6535 let m = self.prob.m;
6536 let con_linear = &self.prob.con_linear;
6537 let quad = &self.quad;
6538 if let Some(h) = &mut self.con_hybrid {
6539 // Shared CSE bodies once for the whole constraint block, then one
6540 // local sweep per summand (pounce#476).
6541 let ConHybrid {
6542 tape,
6543 row_start,
6544 prelude_vals,
6545 local_vals,
6546 ..
6547 } = h;
6548 tape.forward_prelude(x, prelude_vals);
6549 for i in 0..m {
6550 let mut nl: Number = 0.0;
6551 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6552 tape.forward_summand(s, x, prelude_vals, local_vals);
6553 nl += tape.root_value(s, local_vals);
6554 }
6555 if let Some(f) = quad.row_form(i) {
6556 nl += quad.value(f, x);
6557 }
6558 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6559 g[i] = nl + lin;
6560 }
6561 return true;
6562 }
6563 // Row `i` reads only `x` and writes only `g[i]`, so this one is
6564 // parallel as written — no inversion, no row-offset bookkeeping, and
6565 // the per-row arithmetic is untouched, which is why the values are
6566 // bit-identical. Only the forward-value arena is per-worker.
6567 #[cfg(not(target_arch = "wasm32"))]
6568 if self.eval_g_parallel() {
6569 use rayon::prelude::*;
6570 let max_tape_n = self.vals_scratch.len();
6571 let con_tapes = &self.con_tapes;
6572 g.par_iter_mut().enumerate().for_each_init(
6573 || vec![0.0; max_tape_n],
6574 |vals, (i, gi)| {
6575 let mut nl: Number = 0.0;
6576 for t in &con_tapes[i] {
6577 nl += t.eval_into(x, vals);
6578 }
6579 if let Some(f) = quad.row_form(i) {
6580 nl += quad.value(f, x);
6581 }
6582 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6583 *gi = nl + lin;
6584 },
6585 );
6586 return true;
6587 }
6588 let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
6589 for i in 0..m {
6590 let mut nl: Number = 0.0;
6591 for t in &con_tapes[i] {
6592 nl += t.eval_into(x, vals);
6593 }
6594 // A quadratic row's summand range is empty above, so this is its
6595 // whole nonlinear part: one matvec against a constant matrix in
6596 // place of a walk over every monomial's tape.
6597 if let Some(f) = quad.row_form(i) {
6598 nl += quad.value(f, x);
6599 }
6600 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6601 g[i] = nl + lin;
6602 }
6603 true
6604 }
6605
6606 fn eval_jac_g(
6607 &mut self,
6608 x: Option<&[Number]>,
6609 _new_x: bool,
6610 mode: SparsityRequest<'_>,
6611 ) -> bool {
6612 match mode {
6613 SparsityRequest::Structure { irow, jcol } => {
6614 let mut k = 0;
6615 for i in 0..self.prob.m {
6616 for &j in &self.jac_cols[i] {
6617 irow[k] = i as Index;
6618 jcol[k] = j as Index;
6619 k += 1;
6620 }
6621 }
6622 true
6623 }
6624 SparsityRequest::Values { values } => {
6625 let n = self.prob.n;
6626 if self.scratch_row_grad.len() < n {
6627 self.scratch_row_grad.resize(n, 0.0);
6628 }
6629 // Rows are independent and write disjoint runs of `values`, so
6630 // the parallel walk is the same work in the same order, split
6631 // by row group. Only the flat path: the shared-CSE arm below
6632 // forwards one prelude for the whole block, which is exactly
6633 // the sharing a row split would have to undo.
6634 #[cfg(not(target_arch = "wasm32"))]
6635 if self.con_hybrid.as_ref().filter(|h| h.use_for_jac).is_none()
6636 && self.jac_flat_parallel()
6637 {
6638 self.jac_flat_rows_parallel(x, values);
6639 return true;
6640 }
6641 let Self {
6642 prob,
6643 con_tapes,
6644 con_hybrid,
6645 quad,
6646 jac_cols,
6647 scratch_row_grad,
6648 vals_scratch,
6649 adj_scratch,
6650 ..
6651 } = self;
6652 let xs = x.unwrap_or(&prob.x0);
6653 let mut k = 0;
6654 // Shared-CSE path: the forward sweep over the CSE bodies runs
6655 // once for the whole constraint block instead of once per
6656 // referencing summand. The reverse sweep cannot be shared —
6657 // each row needs its own gradient — so a summand still walks
6658 // its own `prelude_reach` backwards.
6659 if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
6660 let ConHybrid {
6661 tape,
6662 row_start,
6663 prelude_vals,
6664 local_vals,
6665 local_adj,
6666 prelude_adj,
6667 ..
6668 } = h;
6669 tape.forward_prelude(xs, prelude_vals);
6670 for i in 0..prob.m {
6671 for &j in &jac_cols[i] {
6672 scratch_row_grad[j] = 0.0;
6673 }
6674 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6675 tape.forward_summand(s, xs, prelude_vals, local_vals);
6676 tape.gradient_summand(
6677 s,
6678 prelude_vals,
6679 local_vals,
6680 1.0,
6681 scratch_row_grad,
6682 local_adj,
6683 prelude_adj,
6684 );
6685 }
6686 if let Some(f) = quad.row_form(i) {
6687 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6688 }
6689 for &(v, c) in &prob.con_linear[i] {
6690 scratch_row_grad[v] += c;
6691 }
6692 for &j in &jac_cols[i] {
6693 values[k] = scratch_row_grad[j];
6694 k += 1;
6695 }
6696 }
6697 return true;
6698 }
6699 for i in 0..prob.m {
6700 for &j in &jac_cols[i] {
6701 scratch_row_grad[j] = 0.0;
6702 }
6703 for t in &con_tapes[i] {
6704 // Allocation-free reverse-AD per summand tape (M18):
6705 // reuse the shared forward/adjoint scratch arenas.
6706 t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
6707 }
6708 // `Hx + a` for a quadratic row: one matvec over the
6709 // row's support, in place of a reverse sweep per
6710 // monomial. `jac_cols[i]` already covers the form's
6711 // gradient support, so the scatter lands inside the
6712 // window zeroed above.
6713 if let Some(f) = quad.row_form(i) {
6714 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6715 }
6716 for &(v, c) in &prob.con_linear[i] {
6717 scratch_row_grad[v] += c;
6718 }
6719 for &j in &jac_cols[i] {
6720 values[k] = scratch_row_grad[j];
6721 k += 1;
6722 }
6723 }
6724 true
6725 }
6726 }
6727 }
6728
6729 fn eval_h(
6730 &mut self,
6731 x: Option<&[Number]>,
6732 _new_x: bool,
6733 obj_factor: Number,
6734 lambda: Option<&[Number]>,
6735 _new_lambda: bool,
6736 mode: SparsityRequest<'_>,
6737 ) -> bool {
6738 match mode {
6739 SparsityRequest::Structure { irow, jcol } => {
6740 irow.copy_from_slice(&self.h_irow);
6741 jcol.copy_from_slice(&self.h_jcol);
6742 true
6743 }
6744 SparsityRequest::Values { values } => {
6745 // Kept alongside the resolved slice: the parallel walk below
6746 // resolves it again *after* destructuring `self`, so `x` and
6747 // the buffers it writes borrow different fields and neither
6748 // has to be copied per evaluation.
6749 let x_arg = x;
6750 let x = x_arg.unwrap_or(&self.prob.x0);
6751 values.fill(0.0);
6752
6753 let obj_seed = if self.prob.minimize {
6754 obj_factor
6755 } else {
6756 -obj_factor
6757 };
6758 // Coloring path. For each (tape, weight) we do
6759 // one forward pass into `vals_scratch`, then one
6760 // forward-tangent+reverse-over-tangent per color
6761 // touched by that tape. Each pass accumulates a
6762 // weighted contribution of (H_tape · seed_c) into
6763 // `compressed[c]`. After all tapes done, we
6764 // decode each color's compressed vector into the
6765 // sparse `values` array.
6766 for buf in &mut self.compressed {
6767 buf.fill(0.0);
6768 }
6769
6770 // The constant blocks first: no forward sweep, no
6771 // directional product, no decode — the multipliers are the
6772 // only thing that changed since the model was read, so this
6773 // is one `values[slot] += w · h` pass per live form. Skipped
6774 // wholesale on a model with nothing recognized, so such a
6775 // model does not pay an `O(m)` scan for a structure that is
6776 // empty.
6777 if !self.quad.is_empty() {
6778 if obj_seed != 0.0 {
6779 if let Some(f) = self.quad.objective_form() {
6780 self.quad.accumulate_hessian(f, obj_seed, values);
6781 }
6782 }
6783 if let Some(lam) = lambda {
6784 for i in 0..self.prob.m {
6785 let w = lam[i];
6786 if w == 0.0 {
6787 continue;
6788 }
6789 if let Some(f) = self.quad.row_form(i) {
6790 self.quad.accumulate_hessian(f, w, values);
6791 }
6792 }
6793 }
6794 }
6795
6796 if obj_seed != 0.0 {
6797 for (ti, t) in self.obj_tapes.iter().enumerate() {
6798 if t.ops.is_empty() {
6799 continue;
6800 }
6801 t.forward_into(x, &mut self.vals_scratch);
6802 for &c in &self.obj_tape_colors[ti] {
6803 t.hessian_directional(
6804 &self.vals_scratch,
6805 &self.seeds[c as usize],
6806 obj_seed,
6807 &mut self.compressed[c as usize],
6808 &mut self.dot_scratch,
6809 &mut self.adj_scratch,
6810 &mut self.adj_dot_scratch,
6811 );
6812 }
6813 }
6814 }
6815
6816 // Computed before the match: its scrutinee takes `self` mutably,
6817 // so a guard cannot also read `self`. Absent on wasm32 along
6818 // with the arm it guards — the arm still has to *typecheck*
6819 // there, dead or not, and the method it calls does not exist.
6820 #[cfg(not(target_arch = "wasm32"))]
6821 let hess_flat_parallel = self.hess_flat_parallel();
6822 match (lambda, self.con_hybrid.as_mut()) {
6823 // Shared-CSE path (issue #557). Per color the prelude's
6824 // second-order work runs ONCE for the whole constraint
6825 // block: one forward tangent, then — because
6826 // reverse-over-tangent is linear in its adjoint seeds —
6827 // one unit-weight reverse sweep over the λ-weighted
6828 // adjoints accumulated by every summand of that color.
6829 // The flat path below repeats both sweeps over the
6830 // inlined CSE body once per referencing summand.
6831 (Some(lam), Some(h)) if h.use_for_hess => {
6832 let ConHybrid {
6833 tape,
6834 prelude_vals,
6835 local_vals_all,
6836 local_off,
6837 summand_row,
6838 hess_color_summands,
6839 hess_color_reach,
6840 hess_color_reach_off,
6841 prelude_dot,
6842 hess_prelude_adj,
6843 prelude_adj_dot,
6844 local_dot,
6845 local_adj,
6846 local_adj_dot,
6847 ..
6848 } = h;
6849 // Forward once (values are color-independent):
6850 // prelude for the block, then each summand of a row
6851 // with a live multiplier into its packed slice.
6852 tape.forward_prelude(x, prelude_vals);
6853 for (si, s) in tape.summands.iter().enumerate() {
6854 if lam[summand_row[si] as usize] == 0.0 {
6855 continue;
6856 }
6857 tape.forward_summand(
6858 s,
6859 x,
6860 prelude_vals,
6861 &mut local_vals_all[local_off[si]..local_off[si + 1]],
6862 );
6863 }
6864 for (c, list) in hess_color_summands.iter().enumerate() {
6865 if !list
6866 .iter()
6867 .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
6868 {
6869 continue;
6870 }
6871 let seed = &self.seeds[c];
6872 let out = &mut self.compressed[c];
6873 let creach = &hess_color_reach
6874 [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
6875 tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
6876 for &si in list {
6877 let si = si as usize;
6878 let w = lam[summand_row[si] as usize];
6879 if w == 0.0 {
6880 continue;
6881 }
6882 tape.hessian_summand_directional(
6883 &tape.summands[si],
6884 &local_vals_all[local_off[si]..local_off[si + 1]],
6885 prelude_dot,
6886 seed,
6887 w,
6888 out,
6889 local_dot,
6890 local_adj,
6891 local_adj_dot,
6892 hess_prelude_adj,
6893 prelude_adj_dot,
6894 );
6895 }
6896 tape.prelude_reverse_directional(
6897 prelude_vals,
6898 prelude_dot,
6899 creach,
6900 out,
6901 hess_prelude_adj,
6902 prelude_adj_dot,
6903 );
6904 }
6905 }
6906 // Color-major and parallel when the work is big enough
6907 // to pay for the dispatch; bit-identical to the walk
6908 // below, which stays the path every small model takes.
6909 #[cfg(not(target_arch = "wasm32"))]
6910 (Some(lam), _) if hess_flat_parallel => {
6911 self.hess_flat_colors_parallel(x_arg, lam);
6912 }
6913 (Some(lam), _) => {
6914 for k in 0..self.prob.m {
6915 let w = lam[k];
6916 if w == 0.0 {
6917 continue;
6918 }
6919 for (ti, t) in self.con_tapes[k].iter().enumerate() {
6920 if t.ops.is_empty() {
6921 continue;
6922 }
6923 t.forward_into(x, &mut self.vals_scratch);
6924 for &c in &self.con_tape_colors[k][ti] {
6925 t.hessian_directional(
6926 &self.vals_scratch,
6927 &self.seeds[c as usize],
6928 w,
6929 &mut self.compressed[c as usize],
6930 &mut self.dot_scratch,
6931 &mut self.adj_scratch,
6932 &mut self.adj_dot_scratch,
6933 );
6934 }
6935 }
6936 }
6937 }
6938 (None, _) => {}
6939 }
6940
6941 // Decode each color's compressed Hessian-vector
6942 // result into the lower-triangle `values` array.
6943 for (c, table) in self.decoding.iter().enumerate() {
6944 let comp = &self.compressed[c];
6945 for w in table {
6946 values[w.hess_idx as usize] += comp[w.row as usize];
6947 }
6948 }
6949 true
6950 }
6951 }
6952 }
6953
6954 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
6955 self.final_x = Some(sol.x.to_vec());
6956 self.final_obj = sol.obj_value;
6957 self.final_lambda = Some(sol.lambda.to_vec());
6958 self.final_z_l = Some(sol.z_l.to_vec());
6959 self.final_z_u = Some(sol.z_u.to_vec());
6960 }
6961
6962 /// Publish the `.col` / `.row` names (captured at load time) under the
6963 /// conventional `idx_names` metadata key, in original `.nl` order. The
6964 /// adapter permutes these into split space (see
6965 /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
6966 /// near-singular Jacobian row as the `mass_balance` equation rather
6967 /// than "row 3" — the model-vs-index gap Lee et al. (2024,
6968 /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
6969 /// model debugging. Declines (returns false) when the model shipped no
6970 /// name files so callers fall back to index labels.
6971 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
6972 let mut any = false;
6973 if !self.prob.var_names.is_empty() {
6974 var.strings
6975 .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
6976 any = true;
6977 }
6978 if !self.prob.con_names.is_empty() {
6979 con.strings
6980 .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
6981 any = true;
6982 }
6983 any
6984 }
6985
6986 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
6987 // A row is linear iff its nonlinear-part expression is the
6988 // identity zero — either left over from initial allocation ("no
6989 // `C<idx>` segment touched this row") or installed by the
6990 // constant-row-body fold in `parse_nl_text`, which shifts a
6991 // variable-free `C<idx>` body into the row bounds precisely so
6992 // that this test is a genuine linearity test and not just an
6993 // identity check (`gh #492`).
6994 for (i, t) in types.iter_mut().enumerate() {
6995 *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
6996 Linearity::Linear
6997 } else {
6998 Linearity::NonLinear
6999 };
7000 }
7001 true
7002 }
7003
7004 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7005 // Global linearity, per the upstream TNLP contract: a variable is
7006 // NonLinear iff it appears in the nonlinear part of the objective
7007 // or of any constraint; otherwise Linear. The parsed `.nl` splits
7008 // every row into a linear part (J/G coefficient list) and a
7009 // nonlinear expression, so the set of nonlinear variables is
7010 // exactly the structural union of `collect_vars` over
7011 // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
7012 // only by a linear part — or not referenced at all — is Linear.
7013 //
7014 let nonlinear = self.nonlinear_var_set();
7015 for (i, t) in types.iter_mut().enumerate() {
7016 *t = if nonlinear.contains(&i) {
7017 Linearity::NonLinear
7018 } else {
7019 Linearity::Linear
7020 };
7021 }
7022 true
7023 }
7024
7025 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
7026 // Objective-scoped variant of `get_variables_linearity`: only
7027 // `obj_nonlinear` contributes. This is what engages the presolve
7028 // auxiliary-elimination safeguard (pounce-presolve H11): a variable
7029 // that is nonlinear in the objective but happens to have a zero
7030 // gradient at the single probe point (e.g. `f = (x - x0)^2`
7031 // warm-started at `x0`) is kept in the objective support instead of
7032 // being mis-classified objective-free and eliminated. A variable
7033 // that is nonlinear only in *constraints* stays `Linear` here, so
7034 // the guard does not block legitimate eliminations of
7035 // objective-free equality blocks (the gas-network case).
7036 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
7037 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
7038 for (i, t) in types.iter_mut().enumerate() {
7039 *t = if nonlinear.contains(&i) {
7040 Linearity::NonLinear
7041 } else {
7042 Linearity::Linear
7043 };
7044 }
7045 true
7046 }
7047
7048 fn get_number_of_nonlinear_variables(&mut self) -> Index {
7049 self.nonlinear_variables().len() as Index
7050 }
7051
7052 fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
7053 let list = self.nonlinear_variables();
7054 if pos_nonlin_vars.len() < list.len() {
7055 return false;
7056 }
7057 pos_nonlin_vars[..list.len()].copy_from_slice(&list);
7058 true
7059 }
7060
7061 fn derivative_proofs(&mut self) -> DerivativeProofs {
7062 // Degree is the whole argument (gh #588, Q6). A body proved
7063 // degree ≤ 1 has a constant gradient and no second derivative; a
7064 // body proved degree 2 has a nonzero second derivative, hence a
7065 // gradient that moves. A body the recognizer refuses is
7066 // `Unknown` — and `Unknown` must stay `Unknown`, because the
7067 // refusal is structural, not a finding of nonlinearity.
7068 fn proof(affine: Option<bool>) -> DerivativeProof {
7069 match affine {
7070 Some(true) => DerivativeProof::Constant,
7071 Some(false) => DerivativeProof::Varying,
7072 None => DerivativeProof::Unknown,
7073 }
7074 }
7075 let obj_affine = self.prob.obj_nonlinear.provably_affine();
7076 let jac: Vec<DerivativeProof> = self
7077 .prob
7078 .con_nonlinear
7079 .iter()
7080 .map(|b| proof(b.provably_affine()))
7081 .collect();
7082
7083 // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
7084 // makes `∇²L` a non-constant function of `λ` — this is the QCQP
7085 // case, and it is exactly the assertion Ipopt would honour and
7086 // get wrong (§4 Lever 2). Otherwise every row must be *proved*
7087 // affine and the objective *proved* degree ≤ 2, at which point
7088 // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
7089 // reuse on `σ` because the restoration phase passes a different
7090 // one.
7091 let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
7092 DerivativeProof::Varying
7093 } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
7094 DerivativeProof::Constant
7095 } else {
7096 DerivativeProof::Unknown
7097 };
7098
7099 DerivativeProofs {
7100 grad_f: proof(obj_affine),
7101 hessian,
7102 jac,
7103 }
7104 }
7105}
7106
7107/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
7108pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
7109 let prob = read_nl_file(path)?;
7110 Ok(Rc::new(RefCell::new(NlTnlp::try_new(prob)?)))
7111}
7112
7113#[cfg(test)]
7114mod tests {
7115 /// `.nl` text for `min sum_i exp(x_i) + x_i^2` whose sum is a left-deep
7116 /// chain of `2n - 1` binary `o0`, wrapped in `wraps` pairs of unary `o16`
7117 /// (negation, an even count so the value is unchanged). The `o0` chain is
7118 /// read as one n-ary `Sum`; the negations are what stays genuinely deep.
7119 fn deep_sum_nl(n: usize, wraps: usize) -> String {
7120 let mut l = vec![
7121 "g3 1 1 0".to_string(),
7122 format!(" {n} 1 1 0 1"),
7123 " 0 1".into(),
7124 " 0 0".into(),
7125 format!(" 0 {n} 0"),
7126 " 0 0 0 1 0".into(),
7127 " 0 0 0 0 0".into(),
7128 format!(" {n} {n}"),
7129 " 0 0".into(),
7130 " 0 0 0 0 0".into(),
7131 "C0".into(),
7132 "n0".into(),
7133 "O0 0".into(),
7134 ];
7135 l.extend(std::iter::repeat_n("o16".to_string(), 2 * wraps));
7136 l.extend(std::iter::repeat_n("o0".to_string(), 2 * n - 1));
7137 for i in 0..n {
7138 l.extend([
7139 "o44".into(),
7140 format!("v{i}"),
7141 "o2".into(),
7142 format!("v{i}"),
7143 format!("v{i}"),
7144 ]);
7145 }
7146 l.extend(["r".into(), "4 0.5".into(), "b".into()]);
7147 l.extend((0..n).map(|_| "0 -1.0 1.0".to_string()));
7148 l.push(format!("k{}", n - 1));
7149 l.extend((0..n - 1).map(|i| (i + 1).to_string()));
7150 l.push(format!("J0 {n}"));
7151 l.extend((0..n).map(|i| format!("{i} 1.0")));
7152 l.push(format!("G0 {n}"));
7153 l.extend((0..n).map(|i| format!("{i} 0.0")));
7154 l.join("\n") + "\n"
7155 }
7156
7157 /// `ralph1` written as AMPL would: `min 2x - y, x >= 0, y >= 0,
7158 /// (y - x) complements y`. The complementarity row is `5 1 2` — flag 1
7159 /// (y has a finite lower bound), variable 2 (1-based).
7160 fn ralph1_nl(row_line: &str, y_bound: &str) -> String {
7161 [
7162 "g3 1 1 0",
7163 " 2 1 1 0 0", // vars, constraints, objectives, ranges, eqns
7164 " 0 0 1 0", // nonlinear cons, objs; ccons: linear, nonlinear
7165 " 0 0", // network constraints
7166 " 0 0 0", // nonlinear vars in constraints, objectives, both
7167 " 0 0 0 1", // linear network vars; functions; arith, flags
7168 " 0 0 0 0 0", // discrete variables
7169 " 2 2", // Jacobian, gradient nonzeros
7170 " 0 0", // max name lengths
7171 " 0 0 0 0 0", // common expressions
7172 "C0",
7173 "n0",
7174 "O0 0",
7175 "n0",
7176 "r",
7177 row_line,
7178 "b",
7179 "2 0",
7180 y_bound,
7181 "k1",
7182 "1",
7183 "J0 2",
7184 "0 -1",
7185 "1 1",
7186 "G0 2",
7187 "0 2",
7188 "1 -1",
7189 ]
7190 .join("\n")
7191 + "\n"
7192 }
7193
7194 #[test]
7195 fn complementarity_rows_are_read_not_refused() {
7196 let p = parse_nl_string(ralph1_nl("5 1 2", "2 0"), false).unwrap();
7197 assert_eq!(
7198 p.complementarity,
7199 vec![NlComplementarity {
7200 row: 0,
7201 var: 1,
7202 flags: 1
7203 }]
7204 );
7205 // Flag 1 (bounded below): the body must be >= 0.
7206 assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7207 assert_eq!((p.x_l[1], p.x_u[1]), (0.0, 1e19));
7208
7209 // Flag 2 (bounded above, y <= 0): the body must be <= 0.
7210 let p = parse_nl_string(ralph1_nl("5 2 2", "1 0"), false).unwrap();
7211 assert_eq!(p.complementarity[0].flags, 2);
7212 assert_eq!((p.g_l[0], p.g_u[0]), (-1e19, 0.0));
7213
7214 // Flag 0 (free variable): the body is an equation, and no pair.
7215 let p = parse_nl_string(ralph1_nl("5 0 2", "3"), false).unwrap();
7216 assert!(p.complementarity.is_empty());
7217 assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 0.0));
7218
7219 // Flag 3 (mixed) is recorded; the lowering is what refuses it.
7220 let p = parse_nl_string(ralph1_nl("5 3 2", "0 0 1"), false).unwrap();
7221 assert_eq!(p.complementarity[0].flags, 3);
7222
7223 // Malformed lines are errors, not silently dropped pairs.
7224 assert!(parse_nl_string(ralph1_nl("5 1", "2 0"), false).is_err());
7225 assert!(parse_nl_string(ralph1_nl("5 1 0", "2 0"), false).is_err());
7226 assert!(parse_nl_string(ralph1_nl("5 7 2", "2 0"), false).is_err());
7227 }
7228
7229 #[test]
7230 fn a_model_without_complementarity_reads_exactly_as_before() {
7231 // The same model with an ordinary `r` line: no pairs, the row's
7232 // bounds as written.
7233 let p = parse_nl_string(ralph1_nl("2 0", "2 0"), false).unwrap();
7234 assert!(p.complementarity.is_empty());
7235 assert_eq!((p.g_l[0], p.g_u[0]), (0.0, 1e19));
7236 }
7237
7238 /// Run `f` on a thread with the stack the CLI and `pounce-py` provide,
7239 /// declared to the reader the way they declare it.
7240 fn on_big_stack(f: impl FnOnce() + Send + 'static) {
7241 std::thread::Builder::new()
7242 .stack_size(1 << 30)
7243 .spawn(move || {
7244 set_parse_stack_budget(Some(1 << 30));
7245 f();
7246 set_parse_stack_budget(None);
7247 })
7248 .unwrap()
7249 .join()
7250 .unwrap();
7251 }
7252
7253 /// A model whose objective is the last of `len` defined variables, each
7254 /// referencing the previous: `V_0 = x0^2`, `V_k = V_{k-1} + x0`, so the
7255 /// objective is `x0^2 + (len - 1) x0`. Every `V` segment is one level
7256 /// deep to the parser; the chain is `len` deep to anything that walks
7257 /// through it.
7258 fn v_chain_nl(len: usize) -> String {
7259 let mut l = vec![
7260 "g3 1 1 0".to_string(),
7261 " 1 1 1 0 1".into(),
7262 " 0 1".into(),
7263 " 0 0".into(),
7264 " 0 1 0".into(),
7265 " 0 0 0 1 0".into(),
7266 format!(" 0 0 {len} 0 0"),
7267 " 1 1".into(),
7268 " 0 0".into(),
7269 " 0 0 0 0 0".into(),
7270 ];
7271 l.extend(["V1 0 0".into(), "o5".into(), "v0".into(), "n2".into()]);
7272 for k in 1..len {
7273 l.extend([
7274 format!("V{} 0 0", k + 1),
7275 "o0".into(),
7276 format!("v{k}"),
7277 "v0".into(),
7278 ]);
7279 }
7280 l.extend(["C0".into(), "n0".into()]);
7281 l.extend(["O0 0".into(), format!("v{len}")]);
7282 l.extend(["r".into(), "4 0.5".into(), "b".into(), "0 -1.0 1.0".into()]);
7283 l.extend(["k0".into(), "J0 1".into(), "0 1.0".into()]);
7284 l.extend(["G0 1".into(), "0 0.0".into()]);
7285 l.join("\n") + "\n"
7286 }
7287
7288 /// gh#986 review item 9: a chain of defined variables is counted by the
7289 /// depth guard. Each `V` segment is shallow to the parser, so the first
7290 /// pass let a chain of any length through, and every pass that walks
7291 /// through a `Cse` (`collect_vars`, the tape builder, drop glue) then
7292 /// recursed once per link -- an abort on an ordinary stack. On a thread
7293 /// that declared no stack (the default budget) a chain far past the guard
7294 /// is now a clean `Err`.
7295 #[test]
7296 fn gh986_a_long_defined_variable_chain_is_refused_not_overflowed() {
7297 let limit = parse_depth_limit() as usize;
7298 let err = parse_nl_text(&v_chain_nl(limit + 50))
7299 .err()
7300 .expect("a V chain past the guard must be an Err");
7301 assert!(err.contains("defined") && err.contains("levels"), "{err}");
7302 // A short chain on the same thread is fine, and evaluates.
7303 let prob = parse_nl_text(&v_chain_nl(limit / 2)).expect("inside the guard");
7304 let x = [0.5];
7305 let want = 0.25 + (limit / 2 - 1) as f64 * 0.5;
7306 let got = eval_expr(&prob.obj_expr(), &x);
7307 assert!((got - want).abs() < 1e-9, "{got} vs {want}");
7308 }
7309
7310 /// gh#986 review item 9: the depth of a `V` body is memoized, so a long
7311 /// chain inside the guard costs time linear in the chain (the first
7312 /// pass's `expr_tree_depth` re-walked the whole chain on every segment).
7313 #[test]
7314 fn gh986_a_long_defined_variable_chain_inside_the_guard_parses() {
7315 on_big_stack(|| {
7316 // Two levels per link (the `o0` and the `Cse` reference), just
7317 // inside this thread's limit.
7318 let len = (parse_depth_limit() as usize - 100) / 2;
7319 let t = std::time::Instant::now();
7320 let prob = parse_nl_text(&v_chain_nl(len)).expect("inside the guard on a big stack");
7321 let got = eval_expr(&prob.obj_expr(), &[0.5]);
7322 let want = 0.25 + (len - 1) as f64 * 0.5;
7323 assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
7324 // Linear. With only `expr_tree_depth` memoized, the parse-time
7325 // quadratic passes still re-walked the chain per segment and this
7326 // ran past 60 s (measured, test profile); seeded, ~50 ms.
7327 let took = t.elapsed();
7328 eprintln!("v chain {len}: parsed and evaluated in {took:?}");
7329 assert!(took.as_secs() < 30, "{took:?}");
7330 });
7331 }
7332
7333 /// gh#986 review item 9: a thread that declares no stack is assumed to
7334 /// have Rust's default thread stack (wasm: 1 MiB), not the CLI's 1 GiB.
7335 #[test]
7336 fn gh986_an_undeclared_stack_gets_the_default_assumption() {
7337 std::thread::spawn(|| {
7338 set_parse_stack_budget(None);
7339 assert_eq!(
7340 parse_depth_limit() as usize,
7341 DEFAULT_ASSUMED_STACK / PARSE_LEVEL_BYTES
7342 );
7343 assert!((parse_depth_limit() as usize) < MAX_PARSE_DEPTH as usize);
7344 })
7345 .join()
7346 .unwrap();
7347 }
7348
7349 /// gh#986: a left-deep `o0` chain of any length is read as one n-ary
7350 /// `Sum` — one level deep — so the issue's n = 20 000 (39 999 nested
7351 /// `o0`) parses, and so does 100k, with the same value as the nested
7352 /// binary tree would have had.
7353 #[test]
7354 fn gh986_long_o0_chain_is_read_as_a_flat_sum() {
7355 on_big_stack(|| {
7356 for n in [5_000usize, 20_000, 100_000] {
7357 let prob = parse_nl_text(&deep_sum_nl(n, 0)).expect("a long o0 sum must parse");
7358 let x: Vec<f64> = (0..n).map(|i| (i % 7) as f64 * 0.1).collect();
7359 let want: f64 = x.iter().map(|v| v.exp() + v * v).sum();
7360 let tree = prob.obj_expr();
7361 assert!(expr_tree_depth(&tree, &Default::default()) < 16, "n = {n}");
7362 let got = eval_expr(&tree, &x);
7363 assert!((got - want).abs() <= 1e-9 * want.abs(), "{got} vs {want}");
7364 }
7365 });
7366 }
7367
7368 /// gh#986: the guard follows the stack the thread declares, so a thread
7369 /// with an ordinary 8 MiB stack refuses deep nesting cleanly — in an
7370 /// unoptimised build (the worst frame size) exactly as in an optimised
7371 /// one — instead of overflowing it. This is the reliability the
7372 /// `parse_nl_text` SIGBUS/SIGSEGV reports lacked.
7373 #[test]
7374 fn gh986_guard_follows_the_declared_stack() {
7375 let stack = 8 << 20;
7376 std::thread::Builder::new()
7377 .stack_size(stack)
7378 .spawn(move || {
7379 set_parse_stack_budget(Some(stack));
7380 assert_eq!(parse_depth_limit() as usize, stack / PARSE_LEVEL_BYTES);
7381 let msg = parse_nl_text(&deep_sum_nl(4, 20_000))
7382 .err()
7383 .expect("40 000 levels cannot fit 8 MiB");
7384 assert!(msg.contains("levels"), "{msg}");
7385 // and a shallow model on the same thread is fine
7386 assert!(parse_nl_text(&deep_sum_nl(4, 50)).is_ok());
7387 set_parse_stack_budget(None);
7388 })
7389 .unwrap()
7390 .join()
7391 .unwrap();
7392 }
7393
7394 /// gh#986: nesting that really is deep (negations) past the guard is
7395 /// refused with an `Err`, at any depth, instead of overflowing the stack;
7396 /// nesting just inside the guard still parses. Both on the stack the
7397 /// callers provide, in debug as in release — the guard's headroom is
7398 /// what this pins.
7399 #[test]
7400 fn gh986_deep_nesting_is_a_clean_error_past_the_guard_and_parses_inside_it() {
7401 on_big_stack(|| {
7402 let deep = parse_nl_text(&deep_sum_nl(4, 30_000));
7403 let msg = deep.err().expect("past the guard must be an Err");
7404 assert!(msg.contains("levels"), "{msg}");
7405 // Just inside the limit this thread's declared stack gives
7406 // (`MAX_PARSE_DEPTH` in an optimised build, 32 768 in an
7407 // unoptimised one, whose worst-case frame is 32 KiB).
7408 let wraps = (parse_depth_limit() as usize - 100) / 2;
7409 assert!(parse_nl_text(&deep_sum_nl(4, wraps)).is_ok());
7410 });
7411 }
7412
7413 use super::*;
7414
7415 /// Compile-time guarantee for the batched-solve path (pounce#126):
7416 /// a parsed problem and the TNLP built from it must be movable to a
7417 /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
7418 /// `!Send` state) into the `Expr` DAG / tape pipeline.
7419 #[test]
7420 fn nl_problem_and_tnlp_are_send() {
7421 fn assert_send<T: Send>() {}
7422 assert_send::<NlProblem>();
7423 assert_send::<NlTnlp>();
7424 assert_send::<Expr>();
7425 }
7426
7427 /// `variant()` patches starting point / bounds on a clone and
7428 /// validates override lengths; the base instance is untouched.
7429 #[test]
7430 fn variant_overrides_bounds_and_x0() {
7431 let p = parse_nl_text(SIMPLE).expect("parse");
7432 let base = NlTnlp::new(p);
7433 let var = base
7434 .variant(&NlVariation {
7435 x0: Some(vec![3.0, 4.0]),
7436 x_l: Some(vec![-1.0, -2.0]),
7437 x_u: Some(vec![5.0, 6.0]),
7438 ..Default::default()
7439 })
7440 .expect("variant");
7441 let mut var = var;
7442 let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
7443 let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
7444 assert!(var.get_bounds_info(BoundsInfo {
7445 x_l: &mut x_l,
7446 x_u: &mut x_u,
7447 g_l: &mut g_l,
7448 g_u: &mut g_u,
7449 }));
7450 assert_eq!(x_l, [-1.0, -2.0]);
7451 assert_eq!(x_u, [5.0, 6.0]);
7452 let mut x = [0.0; 2];
7453 let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
7454 assert!(var.get_starting_point(StartingPoint {
7455 init_x: true,
7456 x: &mut x,
7457 init_z: false,
7458 z_l: &mut zl,
7459 z_u: &mut zu,
7460 init_lambda: false,
7461 lambda: &mut lam,
7462 }));
7463 assert_eq!(x, [3.0, 4.0]);
7464 // Base keeps its parsed (free) bounds.
7465 assert!(base.problem().x_l[0] < -1.0e18);
7466 // Length mismatch is an error, not a panic.
7467 assert!(
7468 base.variant(&NlVariation {
7469 x0: Some(vec![1.0]),
7470 ..Default::default()
7471 })
7472 .is_err()
7473 );
7474 }
7475
7476 /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
7477 /// Header values:
7478 /// line 2: n=2 m=0 num_obj=1 0 0
7479 /// line 3: 0 1 (1 nonlinear objective)
7480 /// line 4: 0 0
7481 /// line 5: 0 2 0 (nonlinear vars in obj=2)
7482 /// line 6: 0 0 0 1
7483 /// line 7: 0 0 0 0 0
7484 /// line 8: 0 0 (no Jacobian nonzeros, no linear obj)
7485 /// line 9: 0 0
7486 /// line 10: 0 0 0 0 0
7487 /// Then `O0 0` followed by an expression tree:
7488 /// `(x0 - 1)^2 + (x1 - 2)^2` =
7489 /// o0
7490 /// o5 (o1 v0 n1) n2
7491 /// o5 (o1 v1 n2) n2
7492 /// Then `b` segment: free for both.
7493 /// gh#987: Gay's ordering places nonlinear integers at the END of each
7494 /// nonlinear block and linear ones at the end of the model.
7495 #[test]
7496 fn discrete_census_locates_nonlinear_and_linear_integer_columns() {
7497 // n = 10: nlvb = 2 (1 int), con-only 3 (1 int), obj-only 1 (1 int),
7498 // then linear, ending in nbv = 1, niv = 1.
7499 let d = DiscreteCensus {
7500 binary: 1,
7501 integer: 1,
7502 nl_both: 1,
7503 nl_cons: 1,
7504 nl_objs: 1,
7505 };
7506 let c = NlCounts {
7507 nl_cons: 1,
7508 nl_objs: 1,
7509 nl_vars_cons: 5,
7510 nl_vars_objs: 6,
7511 nl_vars_both: 2,
7512 };
7513 assert_eq!(d.total(), 5);
7514 assert_eq!(d.integer_columns(10, Some(c)), Some(vec![1, 4, 5, 8, 9]));
7515 // Without the line-5 census the nonlinear columns are unknowable.
7516 assert_eq!(d.integer_columns(10, None), None);
7517 // Linear-only integers need no census.
7518 let lin = DiscreteCensus {
7519 binary: 2,
7520 ..Default::default()
7521 };
7522 assert_eq!(lin.integer_columns(4, None), Some(vec![2, 3]));
7523 // Inconsistent counts: more nonlinear-in-both integers than columns.
7524 let bad = DiscreteCensus {
7525 nl_both: 3,
7526 ..Default::default()
7527 };
7528 assert_eq!(bad.integer_columns(10, Some(c)), None);
7529 }
7530
7531 const SIMPLE: &str = "g3 0 1 0
75322 0 1 0 0
75330 1
75340 0
75350 2 0
75360 0 0 1
75370 0 0 0 0
75380 0
75390 0
75400 0 0 0 0
7541O0 0
7542o0
7543o5
7544o1
7545v0
7546n1
7547n2
7548o5
7549o1
7550v1
7551n2
7552n2
7553b
75543
75553
7556";
7557
7558 #[test]
7559 fn parses_simple_quadratic() {
7560 let p = parse_nl_text(SIMPLE).expect("parse");
7561 assert_eq!(p.n, 2);
7562 assert_eq!(p.m, 0);
7563 assert_eq!(p.num_obj, 1);
7564 // f(0,0) = 1 + 4 = 5
7565 let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
7566 assert!((f - 5.0).abs() < 1e-12);
7567 // f(1,2) = 0
7568 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7569 assert!(f.abs() < 1e-12);
7570 }
7571
7572 #[test]
7573 fn gradient_matches_analytic() {
7574 let p = parse_nl_text(SIMPLE).expect("parse");
7575 let x = [0.5, 1.0];
7576 let mut g = [0.0_f64; 2];
7577 grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
7578 // d/dx0 = 2*(x0-1) = -1.0
7579 // d/dx1 = 2*(x1-2) = -2.0
7580 assert!((g[0] - (-1.0)).abs() < 1e-12);
7581 assert!((g[1] - (-2.0)).abs() < 1e-12);
7582 }
7583
7584 /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
7585 /// with global semantics so the presolve auxiliary-elimination
7586 /// safeguard actually engages. Pre-fix the default trait stub returned
7587 /// `false` and left the slice untouched, so a variable that is
7588 /// nonlinear in the objective but zero-gradient at the probe point
7589 /// could be wrongly eliminated.
7590 ///
7591 /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
7592 /// of the objective (NonLinear); x1 appears only in the linear part
7593 /// (Linear).
7594 #[test]
7595 fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
7596 // (x0 - 1)^2
7597 let obj_nl = Expr::Binary(
7598 BinOp::Pow,
7599 Box::new(Expr::Binary(
7600 BinOp::Sub,
7601 Box::new(Expr::Var(0)),
7602 Box::new(Expr::Const(1.0)),
7603 )),
7604 Box::new(Expr::Const(2.0)),
7605 );
7606 let prob = NlProblem {
7607 src: None,
7608 cse_bodies: Vec::new(),
7609 complementarity: Vec::new(),
7610 n: 2,
7611 m: 0,
7612 num_obj: 1,
7613 minimize: true,
7614 obj_nonlinear: NlBody::Tree(obj_nl),
7615 obj_linear: vec![(1, 3.0)],
7616 obj_constant: 0.0,
7617 con_nonlinear: vec![],
7618 con_linear: vec![],
7619 x_l: vec![f64::NEG_INFINITY; 2],
7620 x_u: vec![f64::INFINITY; 2],
7621 g_l: vec![],
7622 g_u: vec![],
7623 x0: vec![0.0; 2],
7624 lambda0: vec![],
7625 suffixes: NlSuffixes::default(),
7626 imported_funcs: vec![],
7627 ampl_options: vec![],
7628 nl_counts: None,
7629 n_discrete: None,
7630 var_names: vec![],
7631 con_names: vec![],
7632 };
7633 let mut tnlp = NlTnlp::new(prob);
7634 let mut types = vec![Linearity::Linear; 2];
7635 let ok = tnlp.get_variables_linearity(&mut types);
7636 // Pre-fix: default stub returns false (slice untouched).
7637 assert!(
7638 ok,
7639 "get_variables_linearity must report it filled the slice"
7640 );
7641 assert!(
7642 matches!(types[0], Linearity::NonLinear),
7643 "x0 is nonlinear in the objective"
7644 );
7645 assert!(
7646 matches!(types[1], Linearity::Linear),
7647 "x1 appears only in the linear part"
7648 );
7649 }
7650
7651 /// Objective-scoped linearity must NOT inherit constraint
7652 /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
7653 /// (constraint tape) but linear w.r.t. the objective, so the presolve
7654 /// H11 guard must not treat it as objective-coupled — that was the CI
7655 /// regression where every gas-network variable (nonlinear in the flow
7656 /// equations, absent from the linear objective) blocked Phase-0
7657 /// elimination.
7658 #[test]
7659 fn objective_variables_linearity_ignores_constraint_nonlinearity() {
7660 // x0^2
7661 let con_nl = Expr::Binary(
7662 BinOp::Pow,
7663 Box::new(Expr::Var(0)),
7664 Box::new(Expr::Const(2.0)),
7665 );
7666 let prob = NlProblem {
7667 src: None,
7668 cse_bodies: Vec::new(),
7669 complementarity: Vec::new(),
7670 n: 2,
7671 m: 1,
7672 num_obj: 1,
7673 minimize: true,
7674 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
7675 obj_linear: vec![(1, 3.0)],
7676 obj_constant: 0.0,
7677 con_nonlinear: vec![NlBody::Tree(con_nl)],
7678 con_linear: vec![vec![]],
7679 x_l: vec![f64::NEG_INFINITY; 2],
7680 x_u: vec![f64::INFINITY; 2],
7681 g_l: vec![4.0],
7682 g_u: vec![4.0],
7683 x0: vec![0.0; 2],
7684 lambda0: vec![0.0],
7685 suffixes: NlSuffixes::default(),
7686 imported_funcs: vec![],
7687 ampl_options: vec![],
7688 nl_counts: None,
7689 n_discrete: None,
7690 var_names: vec![],
7691 con_names: vec![],
7692 };
7693 let mut tnlp = NlTnlp::new(prob);
7694
7695 let mut global = vec![Linearity::Linear; 2];
7696 assert!(tnlp.get_variables_linearity(&mut global));
7697 assert!(
7698 matches!(global[0], Linearity::NonLinear),
7699 "global tags see x0's constraint nonlinearity"
7700 );
7701
7702 let mut obj = vec![Linearity::NonLinear; 2];
7703 assert!(tnlp.get_objective_variables_linearity(&mut obj));
7704 assert!(
7705 matches!(obj[0], Linearity::Linear),
7706 "x0 is linear w.r.t. the objective despite the nonlinear constraint"
7707 );
7708 assert!(
7709 matches!(obj[1], Linearity::Linear),
7710 "x1 is linear everywhere"
7711 );
7712 }
7713
7714 /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
7715 /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
7716 /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
7717 /// constraints, and both variables nonlinear in the objective only.
7718 #[test]
7719 fn header_census_is_parsed() {
7720 let p = parse_nl_text(SIMPLE).expect("parse");
7721 let c = p.nl_counts.expect("SIMPLE has a well-formed header");
7722 assert_eq!(c.nl_cons, 0);
7723 assert_eq!(c.nl_objs, 1);
7724 assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
7725 assert_eq!(c.nonlinear_vars(), 2);
7726 }
7727
7728 /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
7729 /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
7730 /// as the overlapping one: disjoint sets add, and `max` would be wrong
7731 /// for them.
7732 #[test]
7733 fn nonlinear_var_total_uses_inclusion_exclusion() {
7734 let c = |vc, vo, vb| NlCounts {
7735 nl_cons: 0,
7736 nl_objs: 0,
7737 nl_vars_cons: vc,
7738 nl_vars_objs: vo,
7739 nl_vars_both: vb,
7740 };
7741 // Fully shared: the same 5 variables in both.
7742 assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
7743 // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
7744 // not the `max(nlvc, nlvo) = 1` a naive reading gives.
7745 assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
7746 // Partial overlap: 4 + 3 - 2.
7747 assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
7748 // Nonsense header: saturates instead of underflowing.
7749 assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
7750 }
7751
7752 /// A header that does not carry the documented fields records no
7753 /// census at all rather than a guess of zero — "no nonlinear
7754 /// variables" is a claim, and a truncated header has not made it.
7755 #[test]
7756 fn short_header_line_records_no_census() {
7757 // Line 5 with two fields instead of `nlvc nlvo nlvb`.
7758 let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
7759 assert_ne!(txt, SIMPLE, "the substitution must have applied");
7760 let p = parse_nl_text(&txt).expect("parse");
7761 assert!(p.nl_counts.is_none());
7762 }
7763
7764 /// `get_number_of_nonlinear_variables` used to be the trait default,
7765 /// `-1` ("assume everything is nonlinear"). It now answers from the
7766 /// trees, and `get_list_of_nonlinear_variables` agrees with it.
7767 ///
7768 /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
7769 #[test]
7770 fn nonlinear_variable_list_excludes_linear_columns() {
7771 let obj_nl = Expr::Binary(
7772 BinOp::Pow,
7773 Box::new(Expr::Binary(
7774 BinOp::Sub,
7775 Box::new(Expr::Var(0)),
7776 Box::new(Expr::Const(1.0)),
7777 )),
7778 Box::new(Expr::Const(2.0)),
7779 );
7780 let parts = NlProblemParts {
7781 minimize: true,
7782 objective: obj_nl,
7783 obj_constant: 0.0,
7784 constraints: vec![],
7785 x_l: vec![-1e19; 2],
7786 x_u: vec![1e19; 2],
7787 x0: vec![0.0; 2],
7788 g_l: vec![],
7789 g_u: vec![],
7790 var_names: vec![],
7791 con_names: vec![],
7792 };
7793 let prob = NlProblem::from_expressions(parts).expect("build");
7794 assert!(
7795 prob.nl_counts.is_none(),
7796 "a model built in memory has no header to read"
7797 );
7798 let mut tnlp = NlTnlp::new(prob);
7799 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
7800 let mut list = [-1 as Index; 2];
7801 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7802 assert_eq!(list[0], 0);
7803 }
7804
7805 /// When the header says every variable is nonlinear the walk is
7806 /// skipped, and the answer is the same one the walk would give for
7807 /// `SIMPLE` (both variables appear in the objective's nonlinear part).
7808 #[test]
7809 fn all_nonlinear_header_short_circuits_to_n() {
7810 let p = parse_nl_text(SIMPLE).expect("parse");
7811 assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
7812 let mut tnlp = NlTnlp::new(p);
7813 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
7814 let mut list = [-1 as Index; 2];
7815 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7816 assert_eq!(list, [0, 1]);
7817 }
7818
7819 /// The list must not be written when the caller's slice is too small —
7820 /// the contract's `false` return, not a panic.
7821 #[test]
7822 fn nonlinear_variable_list_declines_a_short_slice() {
7823 let p = parse_nl_text(SIMPLE).expect("parse");
7824 let mut tnlp = NlTnlp::new(p);
7825 let mut list = [-1 as Index; 1];
7826 assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
7827 assert_eq!(list, [-1]);
7828 }
7829
7830 /// `min x0^2 + x1^2 s.t. x0 + x1 = 1`.
7831 /// One equality constraint with a purely linear Jacobian — exercises
7832 /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
7833 /// kind 4).
7834 ///
7835 /// Header layout:
7836 /// line 1: g3 0 1 0
7837 /// line 2: 2 1 1 0 0 (n=2, m=1, num_obj=1)
7838 /// line 3: 0 1 (1 nonlinear obj, 0 nonlinear cons)
7839 /// line 4: 0 0
7840 /// line 5: 0 2 0 (nonlinear vars in obj=2)
7841 /// line 6: 0 0 0 1
7842 /// line 7: 0 0 0 0 0
7843 /// line 8: 2 0 (Jacobian nnz=2, no linear obj)
7844 /// line 9: 0 0
7845 /// line 10: 0 0 0 0 0
7846 /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
7847 /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
7848 const EQ_LIN: &str = "g3 0 1 0
78492 1 1 0 0
78500 1
78510 0
78520 2 0
78530 0 0 1
78540 0 0 0 0
78552 0
78560 0
78570 0 0 0 0
7858C0
7859n0
7860O0 0
7861o0
7862o5
7863v0
7864n2
7865o5
7866v1
7867n2
7868r
78694 1
7870b
78713
78723
7873k1
78742
7875J0 2
78760 1
78771 1
7878";
7879
7880 #[test]
7881 fn parses_constrained_problem() {
7882 let p = parse_nl_text(EQ_LIN).expect("parse");
7883 assert_eq!(p.n, 2);
7884 assert_eq!(p.m, 1);
7885 // r-segment kind 4 (equality with rhs=1).
7886 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7887 assert!((p.g_u[0] - 1.0).abs() < 1e-12);
7888 // J-row 0: x0 (coef 1), x1 (coef 1).
7889 assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
7890 }
7891
7892 #[test]
7893 fn malformed_j_variable_index_is_parse_error_not_panic() {
7894 // Code review L32: a J-segment entry's variable (column) index was
7895 // pushed into con_linear unchecked, so an out-of-range index (here 5
7896 // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
7897 // constraint evaluation. It must instead surface as a clean parse
7898 // error, consistent with the existing `J<row> out of range` check.
7899 let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
7900 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7901 let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
7902 assert!(err.contains("out of range"), "unexpected error: {err}");
7903 }
7904
7905 #[test]
7906 fn out_of_range_x_segment_index_is_parse_error() {
7907 // Same strictness for the initial-primal `x` segment: an index past
7908 // `n` used to be silently dropped; now it is a parse error, so the
7909 // four index-bearing segments (J/G/x/d) behave consistently.
7910 let bad = format!("{EQ_LIN}x1\n5 0.5\n");
7911 let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
7912 assert!(err.contains("out of range"), "unexpected error: {err}");
7913 }
7914
7915 // ---------------------------------------------------------------
7916 // gh #492 — a constant `C<i>` body folds into the row bounds.
7917 //
7918 // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
7919 // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
7920 // constant-body rows to fold.
7921 // ---------------------------------------------------------------
7922
7923 /// Linearity is a *linearity* test, not "did a `C` segment touch this
7924 /// row". A row whose body is the bare constant `3` is affine.
7925 #[test]
7926 fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
7927 // `x0 + x1 + 3 = 1` ⇔ `x0 + x1 = -2`.
7928 let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7929 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7930 let p = parse_nl_text(&nl).expect("parse");
7931
7932 assert!(
7933 p.con_nonlinear[0].is_trivially_zero(),
7934 "the constant body must be replaced by the identity zero, got {:?}",
7935 p.con_nonlinear[0]
7936 );
7937 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7938 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7939
7940 let mut lin = [Linearity::NonLinear];
7941 let mut t = NlTnlp::new(p);
7942 assert!(t.get_constraints_linearity(&mut lin));
7943 assert_eq!(lin[0], Linearity::Linear);
7944 }
7945
7946 /// The shift must be exact, not merely "linear now": the folded model
7947 /// and the hand-folded one must be the same problem, row body for row
7948 /// body and bound for bound. That is what keeps feasibility, the
7949 /// active set, and the duals unchanged.
7950 #[test]
7951 fn folding_a_row_constant_gives_the_hand_folded_problem() {
7952 // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
7953 let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7954 let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
7955 assert_ne!(offset, EQ_LIN);
7956 assert_ne!(folded, EQ_LIN);
7957
7958 let a = parse_nl_text(&offset).expect("parse offset form");
7959 let b = parse_nl_text(&folded).expect("parse folded form");
7960 assert_eq!(a.g_l, b.g_l);
7961 assert_eq!(a.g_u, b.g_u);
7962 assert_eq!(a.con_linear, b.con_linear);
7963
7964 // And the row *values* agree pointwise, which is the property the
7965 // duals ride on: `g(x) - g_l` is the same residual either way.
7966 let mut ga = [0.0];
7967 let mut gb = [0.0];
7968 let x = [0.75, -1.25];
7969 assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
7970 assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
7971 assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
7972 }
7973
7974 /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
7975 /// constant as `n3`, and AMPL emits such trees when a expression
7976 /// collapses without being re-simplified.
7977 #[test]
7978 fn a_row_body_that_evaluates_to_a_constant_folds_too() {
7979 // `C0` = `1 + 2`.
7980 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
7981 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7982 let p = parse_nl_text(&nl).expect("parse");
7983 assert!(p.con_nonlinear[0].is_trivially_zero());
7984 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7985 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7986 }
7987
7988 /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
7989 /// "absent", not "a very large number". Shifting one turns it into a
7990 /// real bound and invents a constraint that is not in the model.
7991 ///
7992 /// The constants here are deliberately huge. An everyday `3` is
7993 /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
7994 /// presence guard would go unnoticed at ordinary magnitudes and then
7995 /// bite on a model that scales its rows. The guard is what makes the
7996 /// sentinel untouchable at *any* magnitude.
7997 #[test]
7998 fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
7999 // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
8000 // bound. Shifting the lower sentinel would leave `-9e18`, a real
8001 // bound, so the row would gain a floor the model never stated.
8002 let nl = EQ_LIN
8003 .replace("C0\nn0\n", "C0\nn-1e18\n")
8004 .replace("r\n4 1\n", "r\n1 1\n");
8005 let p = parse_nl_text(&nl).expect("parse");
8006 assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
8007 assert!(
8008 !lower_bound_present(p.g_l[0]),
8009 "the absent-lower sentinel became a real bound: {}",
8010 p.g_l[0]
8011 );
8012
8013 // The mirror case: a positive constant on a lower-bounded row is
8014 // the one that would pull the *upper* sentinel below 1e19.
8015 let nl = EQ_LIN
8016 .replace("C0\nn0\n", "C0\nn1e18\n")
8017 .replace("r\n4 1\n", "r\n2 1\n");
8018 let p = parse_nl_text(&nl).expect("parse");
8019 assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
8020 assert!(
8021 !upper_bound_present(p.g_u[0]),
8022 "the absent-upper sentinel became a real bound: {}",
8023 p.g_u[0]
8024 );
8025 }
8026
8027 /// A row body that mentions a variable is not a constant, however
8028 /// simple it looks. Folding it would delete the term.
8029 #[test]
8030 fn a_row_body_with_a_variable_is_not_folded() {
8031 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
8032 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8033 let p = parse_nl_text(&nl).expect("parse");
8034 assert!(
8035 !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
8036 "a row in x0 was folded away: {:?}",
8037 p.con_nonlinear[0]
8038 );
8039 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8040 assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
8041 }
8042
8043 /// A variable-free body whose value is not finite is left in place. It
8044 /// makes the row infeasible (or ill-posed) and that is the solver's
8045 /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
8046 /// corrupt the bound pair and take every downstream presence test with
8047 /// it.
8048 #[test]
8049 fn a_non_finite_constant_row_body_is_not_folded() {
8050 // `C0` = `log(-1)` = NaN.
8051 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
8052 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8053 let p = parse_nl_text(&nl).expect("parse");
8054 assert!(
8055 !p.con_nonlinear[0].is_trivially_zero(),
8056 "a NaN body was folded into the bounds"
8057 );
8058 assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
8059 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
8060 }
8061
8062 /// An imported-function call is not a parse-time constant even with
8063 /// constant arguments — it is resolved to a shared library much later
8064 /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
8065 /// rather than guess. The fold must decline before it evaluates.
8066 #[test]
8067 fn a_constant_argument_funcall_row_body_is_not_folded() {
8068 // Declare one imported function and call it with a literal.
8069 let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
8070 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
8071 let p = parse_nl_text(&nl).expect("parse");
8072 assert!(
8073 matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
8074 "expected the funcall to survive the fold, got {:?}",
8075 p.con_nonlinear[0]
8076 );
8077 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
8078 }
8079
8080 #[test]
8081 fn k_segment_nonstandard_count_is_parse_error_at_source() {
8082 // Code review L35: the `k` (Jacobian column-count) segment header
8083 // declares how many count lines follow — `k<count>` — and the
8084 // standard value is n-1. The parser used to *assume* n-1 and ignore
8085 // the header, so a file declaring a different count read the wrong
8086 // number of data lines, desynced the segment stream, and failed far
8087 // downstream with a confusing error (or silently mis-parsed). With
8088 // the declared count now read and validated, a nonstandard count is
8089 // a clear parse error at its source. Here EQ_LIN has n=2 (expected
8090 // count 1); rewrite its `k1` + one count line to `k0`.
8091 let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
8092 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
8093 let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
8094 assert!(
8095 err.contains("k-segment declares"),
8096 "expected a clear k-segment count error, got: {err}"
8097 );
8098 }
8099
8100 #[test]
8101 fn get_starting_point_returns_nl_initial_duals() {
8102 // Code review 2026-06 item M19: the `.nl` `d` segment supplies
8103 // initial constraint multipliers. They are parsed into `lambda0`,
8104 // but `get_starting_point` previously ignored them — so a
8105 // `warm_start_init_point yes` solve silently began from zero duals.
8106 // `get_starting_point` must hand the parsed duals back when the
8107 // engine requests them (`init_lambda`), and leave the buffer
8108 // untouched when it does not.
8109 let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
8110 let p = parse_nl_text(&nl).expect("parse");
8111 // gh#1033: a `d` entry is an AMPL dual, so it is stored negated.
8112 assert_eq!(p.lambda0, vec![-2.5], "the `d` segment fills lambda0");
8113
8114 let mut t = NlTnlp::new(p);
8115 let info = t.get_nlp_info().unwrap();
8116 let (n, m) = (info.n as usize, info.m as usize);
8117
8118 // Warm-start request: init_lambda = true → the parsed `.nl` duals
8119 // must be returned (pre-fix this stayed zero).
8120 let mut x = vec![0.0; n];
8121 let mut z_l = vec![0.0; n];
8122 let mut z_u = vec![0.0; n];
8123 let mut lambda = vec![0.0; m];
8124 assert!(t.get_starting_point(StartingPoint {
8125 init_x: true,
8126 x: &mut x,
8127 init_z: false,
8128 z_l: &mut z_l,
8129 z_u: &mut z_u,
8130 init_lambda: true,
8131 lambda: &mut lambda,
8132 }));
8133 assert_eq!(
8134 lambda,
8135 vec![-2.5],
8136 "a warm start must use the `.nl` initial duals, not zero"
8137 );
8138
8139 // No warm-start request: the multiplier buffer is left alone (the
8140 // engine owns its default), so honoring the flag does not clobber it.
8141 let mut lambda_untouched = vec![7.0; m];
8142 assert!(t.get_starting_point(StartingPoint {
8143 init_x: true,
8144 x: &mut x,
8145 init_z: false,
8146 z_l: &mut z_l,
8147 z_u: &mut z_u,
8148 init_lambda: false,
8149 lambda: &mut lambda_untouched,
8150 }));
8151 assert_eq!(
8152 lambda_untouched,
8153 vec![7.0],
8154 "without init_lambda the multiplier buffer must be untouched"
8155 );
8156 }
8157
8158 /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
8159 /// unconstrained, `n` variables. The trailing product puts a nonzero
8160 /// in row 0 of *every* Hessian column, which is the shape that used
8161 /// to force the greedy coloring to hand out one color per variable.
8162 fn dense_row_objective_nl(n: usize) -> String {
8163 let mut s = String::new();
8164 s.push_str("g3 1 1 0\n");
8165 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8166 s.push_str(" 0 1\n 0 0\n");
8167 s.push_str(&format!(" {n} {n} {n}\n"));
8168 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8169 s.push_str(&format!(" 0 {n}\n"));
8170 s.push_str(" 0 0\n 0 0 0 0 0\n");
8171 // objective
8172 s.push_str("O0 0\n");
8173 s.push_str(&format!("o54\n{}\n", n + 1));
8174 for j in 0..n {
8175 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8176 }
8177 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8178 for j in 0..n {
8179 s.push_str(&format!("v{j}\n"));
8180 }
8181 // start, bounds, gradient
8182 s.push_str(&format!("x{n}\n"));
8183 for j in 0..n {
8184 s.push_str(&format!("{j} 0.5\n"));
8185 }
8186 s.push_str("b\n");
8187 for _ in 0..n {
8188 s.push_str("3\n");
8189 }
8190 s.push_str(&format!("G0 {n}\n"));
8191 for j in 0..n {
8192 s.push_str(&format!("{j} 0.0\n"));
8193 }
8194 s
8195 }
8196
8197 /// Same shape as [`dense_row_objective_nl`], but the coupling term
8198 /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
8199 /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
8200 /// magnitude makes the dense column ill-scaled — the case where
8201 /// reading its entries out of its own pass costs real digits.
8202 fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
8203 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8204 let mut s = String::new();
8205 s.push_str("g3 1 1 0\n");
8206 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8207 s.push_str(" 0 1\n 0 0\n");
8208 s.push_str(&format!(" {n} {n} {n}\n"));
8209 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8210 s.push_str(&format!(" 0 {n}\n"));
8211 s.push_str(" 0 0\n 0 0 0 0 0\n");
8212 s.push_str("O0 0\n");
8213 s.push_str(&format!("o54\n{}\n", n + 1));
8214 for j in 0..n {
8215 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8216 }
8217 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
8218 for j in 0..n {
8219 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8220 }
8221 s.push_str(&format!("x{n}\n"));
8222 for j in 0..n {
8223 s.push_str(&format!("{j} 0.5\n"));
8224 }
8225 s.push_str("b\n");
8226 for _ in 0..n {
8227 s.push_str("3\n");
8228 }
8229 s.push_str(&format!("G0 {n}\n"));
8230 for j in 0..n {
8231 s.push_str(&format!("{j} 0.0\n"));
8232 }
8233 s
8234 }
8235
8236 /// Locate a model in the benchmark corpus, or `None` if the corpus is
8237 /// not on this machine.
8238 ///
8239 /// The corpus is ~2 GB and deliberately outside the checkout (see
8240 /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
8241 /// no-op rather than fail. That is a real limitation — a check that
8242 /// silently does nothing is how the gap below went unnoticed in the
8243 /// first place — so anything using this must also be covered by a
8244 /// synthetic case that always runs.
8245 fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
8246 let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
8247 let p = std::path::PathBuf::from(root).join(rel);
8248 p.is_file().then_some(p)
8249 }
8250
8251 /// The corpus check the dense-column optimization never got.
8252 ///
8253 /// `cho_parmest` is the model whose certificate the optimization cost,
8254 /// and it could not have caught it: the model is 4.3 MB and lives in
8255 /// the benchmark data set, not in the repository, so validating
8256 /// against the in-repo `.nl` fixtures said nothing about it. This test
8257 /// closes that by checking the corpus directly wherever the corpus
8258 /// exists, which is every machine and CI job that runs the benchmarks.
8259 ///
8260 /// The assertion is the one that matters and the one that was never
8261 /// made: whatever the guard leaves peeled must decode to the same
8262 /// Hessian as peeling nothing. Against the pre-fix code this fails —
8263 /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
8264 #[test]
8265 fn cho_parmest_decodes_to_its_unpeeled_reference() {
8266 let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
8267 eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
8268 return;
8269 };
8270 let p = read_nl_file(&path).expect("read cho_parmest");
8271 let n = p.n;
8272 let mut t = NlTnlp::new(p);
8273 // The guard vetoes 7 of cho's 12 peeled columns, and putting those
8274 // seven dense rows back into the conflict graph costs the coloring
8275 // outright: it goes from 17 colors to 9010, and no column clears the
8276 // density threshold afterwards, so the model ends up fully unpeeled.
8277 // That is the price of the certificate on this model, and it is worth
8278 // knowing rather than assuming the other five survive.
8279 assert!(t.peeled_cols.is_empty());
8280
8281 let info = t.get_nlp_info().unwrap();
8282 let nnz = info.nnz_h_lag as usize;
8283 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8284 assert!(t.eval_h(
8285 None,
8286 true,
8287 1.0,
8288 None,
8289 true,
8290 SparsityRequest::Structure {
8291 irow: &mut irow,
8292 jcol: &mut jcol
8293 }
8294 ));
8295 // Evaluate away from `x0` with non-uniform multipliers. The damage
8296 // is point-dependent, and `x0` is close to where it is least
8297 // visible: only 5 entries move there, against 48,931 here. Note the
8298 // guard's own probe runs at `x0` — it fires anyway because it tests
8299 // a bound on what a pass *could* lose, not the damage it happens to
8300 // commit at one point. That is the property that makes it robust,
8301 // and this asymmetry is worth keeping in front of anyone who
8302 // retunes it.
8303 let x: Vec<f64> = t
8304 .prob
8305 .x0
8306 .iter()
8307 .enumerate()
8308 .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
8309 .collect();
8310 let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
8311
8312 let mut got = vec![0.0_f64; nnz];
8313 assert!(t.eval_h(
8314 Some(&x),
8315 true,
8316 1.0,
8317 Some(&lambda),
8318 true,
8319 SparsityRequest::Values { values: &mut got }
8320 ));
8321
8322 t.recolor(&vec![true; n]);
8323 assert!(t.peeled_cols.is_empty());
8324 let mut want = vec![0.0_f64; nnz];
8325 assert!(t.eval_h(
8326 Some(&x),
8327 true,
8328 1.0,
8329 Some(&lambda),
8330 true,
8331 SparsityRequest::Values { values: &mut want }
8332 ));
8333
8334 let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
8335 let mut worst = 0.0_f64;
8336 let mut at = 0usize;
8337 for k in 0..nnz {
8338 let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8339 if rel > worst {
8340 worst = rel;
8341 at = k;
8342 }
8343 }
8344 assert!(
8345 worst <= 1e-13,
8346 "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
8347 {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
8348 out of a pass it cannot be read out of.",
8349 irow[at],
8350 jcol[at],
8351 got[at],
8352 want[at]
8353 );
8354
8355 // The check above passes trivially on correct code, because the
8356 // guard leaves cho fully unpeeled and so compares a configuration
8357 // against itself. It only has teeth against a regression. So prove
8358 // the guard is doing necessary work rather than assuming it: put
8359 // the peels back the way the pre-fix reader had them and confirm
8360 // the Hessian really does come apart.
8361 t.recolor(&vec![false; n]);
8362 assert!(
8363 !t.peeled_cols.is_empty(),
8364 "restoring the unvetoed coloring must peel again"
8365 );
8366 let mut unguarded = vec![0.0_f64; nnz];
8367 assert!(t.eval_h(
8368 Some(&x),
8369 true,
8370 1.0,
8371 Some(&lambda),
8372 true,
8373 SparsityRequest::Values {
8374 values: &mut unguarded
8375 }
8376 ));
8377 let mut bad = 0usize;
8378 let mut worst_unguarded = 0.0_f64;
8379 for k in 0..nnz {
8380 let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8381 if rel > 1e-13 {
8382 bad += 1;
8383 }
8384 worst_unguarded = worst_unguarded.max(rel);
8385 }
8386 // Two different counts get quoted about this model and they measure
8387 // different things: 48,931 of the 96,000 entries differ from the
8388 // uncompressed reference *at all*, down to the last bit, while 88
8389 // exceed 1e-13 relative. The second is the one worth asserting on.
8390 assert!(
8391 bad >= 50 && worst_unguarded > 1e-11,
8392 "peeling cho_parmest unguarded should damage the entries the guard \
8393 exists to protect (measured: 88 entries past 1e-13 relative, \
8394 worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
8395 this fires, the model or the corpus changed and the guard's \
8396 calibration should be re-derived rather than the bound relaxed."
8397 );
8398 eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
8399 }
8400
8401 /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
8402 /// the dense variable is `x_{n-1}` instead of `x_0`:
8403 /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
8404 ///
8405 /// The index matters, and it is the whole reason this helper exists.
8406 /// With the dense variable at 0 every coupling entry is stored as
8407 /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
8408 /// out of column 0's own pass whether or not peeling is on, so the two
8409 /// paths are bit-identical and no test built on that shape can tell
8410 /// them apart. Putting the dense variable last stores them as
8411 /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
8412 /// back from column `n-1`'s pass by symmetry, carrying that pass's
8413 /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
8414 /// That is the category every one of `cho_parmest`'s 48,931 damaged
8415 /// entries fell into.
8416 fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
8417 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
8418 let d = n - 1;
8419 let mut s = String::new();
8420 s.push_str("g3 1 1 0\n");
8421 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8422 s.push_str(" 0 1\n 0 0\n");
8423 s.push_str(&format!(" {n} {n} {n}\n"));
8424 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8425 s.push_str(&format!(" 0 {n}\n"));
8426 s.push_str(" 0 0\n 0 0 0 0 0\n");
8427 s.push_str("O0 0\n");
8428 s.push_str(&format!("o54\n{}\n", n + 1));
8429 for j in 0..n {
8430 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8431 }
8432 s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
8433 for j in 0..d {
8434 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8435 }
8436 s.push_str(&format!("x{n}\n"));
8437 for j in 0..n {
8438 s.push_str(&format!("{j} 0.5\n"));
8439 }
8440 s.push_str("b\n");
8441 for _ in 0..n {
8442 s.push_str("3\n");
8443 }
8444 s.push_str(&format!("G0 {n}\n"));
8445 for j in 0..n {
8446 s.push_str(&format!("{j} 0.0\n"));
8447 }
8448 s
8449 }
8450
8451 /// A well-scaled dense column is still peeled, and the entries read
8452 /// out of its pass are exact — the property the peel guard must not
8453 /// cost us.
8454 #[test]
8455 fn a_well_scaled_dense_column_is_still_peeled() {
8456 let n = 200;
8457 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
8458 let t = NlTnlp::new(p);
8459 assert_eq!(
8460 t.peeled_cols,
8461 vec![0],
8462 "a dense column that costs no accuracy must stay peeled"
8463 );
8464 assert!(
8465 t.seeds.len() <= 4,
8466 "peeling should keep the color count at O(1), got {}",
8467 t.seeds.len()
8468 );
8469 }
8470
8471 /// An ill-scaled dense column must be un-peeled and colored the
8472 /// ordinary way, so its small entries come back to full precision.
8473 ///
8474 /// This test covers the *guard*, not the damage. It asserts that a
8475 /// column spanning 12 orders is un-peeled and that its entries are then
8476 /// exact — and it is worth being precise that it does not, and cannot,
8477 /// show what peeling would have cost, because on this model peeling
8478 /// costs nothing: force the peel through and every entry still comes
8479 /// back bit-identical to its analytic weight. The Hessian here is
8480 /// constant and each entry is a single product, so there is no
8481 /// accumulation for a large-magnitude pass to pollute.
8482 ///
8483 /// Reproducing the actual digit loss takes a model whose entries are
8484 /// summed through shared intermediates, which is why the demonstration
8485 /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
8486 /// real model rather than a synthetic one.
8487 #[test]
8488 fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
8489 let n = 200;
8490 let span = 12.0;
8491 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8492 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
8493 let mut t = NlTnlp::new(p);
8494
8495 assert!(
8496 t.peeled_cols.is_empty(),
8497 "a dense column spanning {span} orders must not be peeled; got {:?}",
8498 t.peeled_cols
8499 );
8500
8501 let info = t.get_nlp_info().unwrap();
8502 let nnz = info.nnz_h_lag as usize;
8503 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8504 assert!(t.eval_h(
8505 None,
8506 true,
8507 1.0,
8508 None,
8509 true,
8510 SparsityRequest::Structure {
8511 irow: &mut irow,
8512 jcol: &mut jcol
8513 }
8514 ));
8515 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8516 let mut vals = vec![0.0_f64; nnz];
8517 assert!(t.eval_h(
8518 Some(&x),
8519 true,
8520 1.0,
8521 None,
8522 true,
8523 SparsityRequest::Values { values: &mut vals }
8524 ));
8525
8526 // Every coupling entry must be its weight to full relative
8527 // precision. Peeled, the smallest of them came back with a
8528 // relative error near 1e-4.
8529 let mut checked = 0;
8530 for k in 0..nnz {
8531 let (i, j) = (irow[k] as usize, jcol[k] as usize);
8532 if i == j {
8533 continue;
8534 }
8535 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8536 checked += 1;
8537 let want = w(i);
8538 assert!(
8539 (vals[k] - want).abs() <= 1e-13 * want,
8540 "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
8541 vals[k],
8542 (vals[k] - want).abs() / want
8543 );
8544 }
8545 assert_eq!(checked, n - 1);
8546 }
8547
8548 /// Whatever survives the peel guard must decode to the same Hessian
8549 /// that not peeling at all produces — every entry, not just the
8550 /// objective.
8551 ///
8552 /// This exists because the original dense-column optimization was
8553 /// validated by running the repository's `.nl` fixtures and comparing
8554 /// objective value and exit status, which was doubly blind: not one of
8555 /// the 60 fixtures has a Hessian row dense enough to peel anything, so
8556 /// the decode path under test never executed, and even had it executed,
8557 /// the defect cost digits in the multipliers while leaving the
8558 /// objective intact. So the instrument has to be the assembled Hessian
8559 /// and the input has to actually peel — hence `peeled_any` below, which
8560 /// fails if the sweep ever goes vacuous the way the fixture suite
8561 /// silently did.
8562 ///
8563 /// What this catches is a *decode* fault: an entry recovered from the
8564 /// wrong pass, which is wrong by O(1). It would not have caught the
8565 /// `cho_parmest` stall, because these synthetic models lose no
8566 /// precision under peeling at all (see
8567 /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
8568 /// precision half is covered against the real model in
8569 /// `cho_parmest_decodes_to_its_unpeeled_reference`.
8570 #[test]
8571 fn a_peeled_decode_matches_an_unpeeled_reference() {
8572 // `last = true` puts the dense variable at `n-1`, so the coupling
8573 // entries are stored with the peeled column as their *row* — the
8574 // only shape in which peeling and not peeling read an entry out of
8575 // different passes, and so the only shape that can detect a
8576 // difference at all. See `weighted_dense_last_col_nl`.
8577 let cases = [
8578 (200, 0.0, false),
8579 (200, 2.0, false),
8580 (200, 0.0, true),
8581 (200, 1.0, true),
8582 (200, 2.0, true),
8583 (400, 3.0, true),
8584 (600, 0.0, true),
8585 ];
8586 let mut peeled_any = false;
8587 let mut worst = 0.0_f64;
8588
8589 for &(n, span, last) in &cases {
8590 let text = if last {
8591 weighted_dense_last_col_nl(n, span)
8592 } else {
8593 weighted_dense_row_objective_nl(n, span)
8594 };
8595 let p = parse_nl_text(&text).expect("parse");
8596 let mut t = NlTnlp::new(p);
8597 let peeled = t.peeled_cols.clone();
8598 peeled_any |= !peeled.is_empty();
8599
8600 let info = t.get_nlp_info().unwrap();
8601 let nnz = info.nnz_h_lag as usize;
8602 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8603 assert!(t.eval_h(
8604 None,
8605 true,
8606 1.0,
8607 None,
8608 true,
8609 SparsityRequest::Structure {
8610 irow: &mut irow,
8611 jcol: &mut jcol
8612 }
8613 ));
8614 let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
8615
8616 let mut got = vec![0.0_f64; nnz];
8617 assert!(t.eval_h(
8618 Some(&x),
8619 true,
8620 1.0,
8621 None,
8622 true,
8623 SparsityRequest::Values { values: &mut got }
8624 ));
8625
8626 // Same object, same tapes, same point — only the coloring
8627 // differs, so any disagreement is the decode path.
8628 t.recolor(&vec![true; n]);
8629 assert!(
8630 t.peeled_cols.is_empty(),
8631 "a fully vetoed model must peel nothing"
8632 );
8633 let mut want = vec![0.0_f64; nnz];
8634 assert!(t.eval_h(
8635 Some(&x),
8636 true,
8637 1.0,
8638 None,
8639 true,
8640 SparsityRequest::Values { values: &mut want }
8641 ));
8642
8643 for k in 0..nnz {
8644 let scale = want[k].abs().max(f64::MIN_POSITIVE);
8645 let rel = (got[k] - want[k]).abs() / scale;
8646 worst = worst.max(rel);
8647 assert!(
8648 rel <= 1e-13,
8649 "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
8650 unpeeled reference {:e} (relative error {rel:e})",
8651 irow[k],
8652 jcol[k],
8653 got[k],
8654 want[k]
8655 );
8656 }
8657 }
8658
8659 assert!(
8660 peeled_any,
8661 "no case peeled anything, so this test proved nothing about the \
8662 decode path — the exact way the fixture suite missed the bug"
8663 );
8664 assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
8665 }
8666
8667 /// `peel_veto` bars a column from the peel set, and the row it puts
8668 /// back into the conflict structure costs the colors it used to save.
8669 #[test]
8670 fn a_vetoed_column_is_colored_the_ordinary_way() {
8671 let n = 300;
8672 // One dense row plus a diagonal: column 0 touches every row.
8673 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8674 pairs.extend((1..n).map(|i| (i, 0)));
8675 pairs.sort_unstable();
8676
8677 let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8678 assert!(peeled[0], "the dense column should peel by default");
8679 assert!(
8680 colors_peeled <= 4,
8681 "peeling should collapse the count, got {colors_peeled}"
8682 );
8683
8684 let mut veto = vec![false; n];
8685 veto[0] = true;
8686 let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
8687 assert!(!peeled[0], "a vetoed column must not be peeled");
8688 assert!(
8689 colors_vetoed > colors_peeled,
8690 "un-peeling restores row 0's conflicts, so colors must rise: \
8691 {colors_vetoed} vs {colors_peeled}"
8692 );
8693 }
8694
8695 /// A single dense Hessian row must not blow the coloring up to one
8696 /// color per variable. It used to: every column shares row 0, so no
8697 /// two columns could be colored alike, and `seeds` / `compressed`
8698 /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
8699 /// per `eval_h` on a Hessian holding only ~2n nonzeros.
8700 #[test]
8701 fn a_dense_hessian_row_does_not_explode_the_coloring() {
8702 let n = 200;
8703 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8704 let t = NlTnlp::new(p);
8705 // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
8706 // for j >= 1 from the coupling term.
8707 assert_eq!(t.h_irow.len(), 2 * n - 1);
8708 assert!(
8709 t.seeds.len() <= 4,
8710 "one dense row should cost one extra color, not n; got {} colors for n={n}",
8711 t.seeds.len()
8712 );
8713 assert_eq!(t.seeds.len(), t.compressed.len());
8714 }
8715
8716 /// Peeling changes *which* directional product recovers an entry, so
8717 /// the recovered Hessian must still be exactly right — including the
8718 /// entries read out of a peeled column's pass by symmetry.
8719 #[test]
8720 fn peeled_dense_column_still_recovers_the_exact_hessian() {
8721 let n = 200;
8722 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8723 let mut t = NlTnlp::new(p);
8724 let info = t.get_nlp_info().unwrap();
8725 let nnz = info.nnz_h_lag as usize;
8726
8727 let mut irow = vec![0_i32; nnz];
8728 let mut jcol = vec![0_i32; nnz];
8729 assert!(t.eval_h(
8730 None,
8731 true,
8732 1.0,
8733 None,
8734 true,
8735 SparsityRequest::Structure {
8736 irow: &mut irow,
8737 jcol: &mut jcol
8738 }
8739 ));
8740
8741 // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
8742 // H[0,0] = 2 + 2 = 4; H[j,j] = 2 (j >= 1); H[j,0] = 1 (j >= 1).
8743 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8744 let obj_factor = 2.5;
8745 let mut vals = vec![0.0_f64; nnz];
8746 assert!(t.eval_h(
8747 Some(&x),
8748 true,
8749 obj_factor,
8750 None,
8751 true,
8752 SparsityRequest::Values { values: &mut vals }
8753 ));
8754
8755 let mut seen_diag = 0;
8756 let mut seen_coupling = 0;
8757 for k in 0..nnz {
8758 let (i, j) = (irow[k] as usize, jcol[k] as usize);
8759 let want = if i == 0 && j == 0 {
8760 4.0
8761 } else if i == j {
8762 seen_diag += 1;
8763 2.0
8764 } else {
8765 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8766 seen_coupling += 1;
8767 1.0
8768 } * obj_factor;
8769 assert!(
8770 (vals[k] - want).abs() < 1e-12,
8771 "H[{i},{j}] = {}, want {want}",
8772 vals[k]
8773 );
8774 }
8775 assert_eq!(seen_diag, n - 1);
8776 assert_eq!(seen_coupling, n - 1);
8777 }
8778
8779 /// The peeling threshold must leave ordinary sparse models on
8780 /// exactly the coloring they had before: a banded Hessian colors by
8781 /// its bandwidth, with nothing peeled.
8782 #[test]
8783 fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
8784 let n = 400;
8785 let pairs: Vec<(usize, usize)> = (0..n)
8786 .flat_map(|j| {
8787 let mut v = vec![(j, j)];
8788 if j + 1 < n {
8789 v.push((j + 1, j));
8790 }
8791 v
8792 })
8793 .collect();
8794 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8795 assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
8796 assert!(
8797 n_colors <= 3,
8798 "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
8799 );
8800 assert!(var_color.iter().all(|&c| c != u32::MAX));
8801 }
8802
8803 /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
8804 /// scattered through an otherwise diagonal `n`-variable problem.
8805 /// A plain coloring needs exactly `size` colors no matter how many
8806 /// blocks there are, because the blocks share no rows.
8807 fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
8808 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8809 let stride = n / blocks;
8810 for b in 0..blocks {
8811 let base = b * stride;
8812 for i in 0..size {
8813 for j in 0..=i {
8814 if i != j {
8815 pairs.push((base + i, base + j));
8816 }
8817 }
8818 }
8819 }
8820 pairs
8821 }
8822
8823 /// Many medium-degree columns must not be peeled.
8824 ///
8825 /// Regression: selecting candidates by "degree > 16x average" and then
8826 /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
8827 /// The columns that miss the cut stay in the conflict structure, so the
8828 /// base color count is untouched and the singleton colors are pure
8829 /// addition — these two patterns colored to 306 and 290 against a plain
8830 /// walk's 50 and 34.
8831 #[test]
8832 fn thousands_of_medium_degree_cols_are_not_peeled() {
8833 for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
8834 let pairs = disjoint_blocks(n, blocks, size);
8835 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8836 let n_peeled = peeled.iter().filter(|&&p| p).count();
8837 assert_eq!(
8838 n_peeled, 0,
8839 "degree-{size} columns do not pay for a singleton color \
8840 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
8841 );
8842 assert!(
8843 n_colors <= size + 1,
8844 "disjoint {size}x{size} blocks color by block size regardless \
8845 of block count; got {n_colors} for n={n}, blocks={blocks}"
8846 );
8847 }
8848 }
8849
8850 /// The pay-for-itself rule must not throw away the case peeling exists
8851 /// for: a handful of genuinely dense rows still get peeled, and still
8852 /// collapse the coloring.
8853 #[test]
8854 fn a_few_truly_dense_rows_are_still_peeled() {
8855 let n = 5_000;
8856 let dense_rows = 4;
8857 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8858 for d in 0..dense_rows {
8859 for j in 0..n {
8860 if j != d {
8861 pairs.push((j.max(d), j.min(d)));
8862 }
8863 }
8864 }
8865 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8866 let n_peeled = peeled.iter().filter(|&&p| p).count();
8867 assert_eq!(n_peeled, dense_rows, "every full row should peel");
8868 assert!(
8869 n_colors <= dense_rows + 2,
8870 "peeling {dense_rows} full rows should leave a diagonal remainder, \
8871 got {n_colors} colors"
8872 );
8873 }
8874
8875 /// The cap still binds, and when it does the kept columns are the
8876 /// highest-degree ones.
8877 #[test]
8878 fn peeling_is_capped_and_keeps_the_worst_offenders() {
8879 let n = 20_000;
8880 let dense_rows = 400;
8881 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8882 for d in 0..dense_rows {
8883 // Row d touches the first (n - d) columns, so degree strictly
8884 // decreases with d and the ordering is unambiguous.
8885 for j in 0..(n - d) {
8886 if j != d {
8887 pairs.push((j.max(d), j.min(d)));
8888 }
8889 }
8890 }
8891 let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8892 let n_peeled = peeled.iter().filter(|&&p| p).count();
8893 assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
8894 assert!(
8895 (0..MAX_PEELED_COLS).all(|d| peeled[d]),
8896 "the {MAX_PEELED_COLS} densest rows are the ones kept"
8897 );
8898 }
8899
8900 /// Header line 0 is `g<count> <opt0> ...`; the option words are the
8901 /// model's own and a solver echoes them into the `.sol` `Options`
8902 /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
8903 #[test]
8904 fn header_option_words_are_kept_verbatim() {
8905 let p = parse_nl_text(EQ_LIN).expect("parse");
8906 assert_eq!(p.ampl_options, vec![0, 1, 0]);
8907 }
8908
8909 /// A count that does not match the words present must not be
8910 /// guessed at — the writer falls back rather than emit a wrong block.
8911 #[test]
8912 fn a_truncated_option_list_is_dropped_not_padded() {
8913 let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
8914 let p = parse_nl_text(&text).expect("parse");
8915 assert!(
8916 p.ampl_options.is_empty(),
8917 "9 declared but 3 present: {:?}",
8918 p.ampl_options
8919 );
8920 }
8921
8922 #[test]
8923 fn constrained_tnlp_eval_g_jac_h() {
8924 let p = parse_nl_text(EQ_LIN).expect("parse");
8925 let mut t = NlTnlp::new(p);
8926 let info = t.get_nlp_info().unwrap();
8927 assert_eq!(info.m, 1);
8928 assert_eq!(info.nnz_jac_g, 2);
8929
8930 // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
8931 let mut g = [0.0_f64; 1];
8932 assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
8933 assert!((g[0] - 0.7).abs() < 1e-12);
8934
8935 // Jacobian structure: row 0, cols [0, 1].
8936 let mut irow = [0_i32; 2];
8937 let mut jcol = [0_i32; 2];
8938 assert!(t.eval_jac_g(
8939 None,
8940 true,
8941 SparsityRequest::Structure {
8942 irow: &mut irow,
8943 jcol: &mut jcol
8944 }
8945 ));
8946 assert_eq!(irow, [0, 0]);
8947 assert_eq!(jcol, [0, 1]);
8948
8949 // Jacobian values: both 1.0.
8950 let mut vals = [0.0_f64; 2];
8951 assert!(t.eval_jac_g(
8952 Some(&[0.3, 0.4]),
8953 true,
8954 SparsityRequest::Values { values: &mut vals }
8955 ));
8956 assert!((vals[0] - 1.0).abs() < 1e-12);
8957 assert!((vals[1] - 1.0).abs() < 1e-12);
8958
8959 // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
8960 // λ contributes nothing because the constraint is linear, and
8961 // x0^2 + x1^2 is separable so there's no (1,0) entry in the
8962 // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
8963 assert_eq!(info.nnz_h_lag, 2);
8964 let mut hirow = [0_i32; 2];
8965 let mut hjcol = [0_i32; 2];
8966 assert!(t.eval_h(
8967 None,
8968 true,
8969 1.0,
8970 None,
8971 true,
8972 SparsityRequest::Structure {
8973 irow: &mut hirow,
8974 jcol: &mut hjcol
8975 }
8976 ));
8977 assert_eq!(hirow, [0, 1]);
8978 assert_eq!(hjcol, [0, 1]);
8979 let mut hvals = [0.0_f64; 2];
8980 assert!(t.eval_h(
8981 Some(&[0.3, 0.4]),
8982 true,
8983 1.0,
8984 Some(&[0.5]),
8985 true,
8986 SparsityRequest::Values { values: &mut hvals }
8987 ));
8988 assert!((hvals[0] - 2.0).abs() < 1e-12);
8989 assert!((hvals[1] - 2.0).abs() < 1e-12);
8990 }
8991
8992 /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
8993 /// encoded as common-subexpression `V2`. Header line 10 declares
8994 /// one obj-only CSE; expression tree references `v2` twice.
8995 const CSE_OBJ: &str = "g3 0 1 0
89962 0 1 0 0
89970 1
89980 0
89990 2 0
90000 0 0 1
90010 0 0 0 0
90020 0
90030 0
90040 1 0 0 0
9005V2 0 0
9006o0
9007v0
9008v1
9009O0 0
9010o0
9011o5
9012v2
9013n2
9014v2
9015b
90163
90173
9018";
9019
9020 #[test]
9021 fn parses_v_segment_cse() {
9022 let p = parse_nl_text(CSE_OBJ).expect("parse");
9023 assert_eq!(p.n, 2);
9024 // f(1,2) = 9 + 3 = 12
9025 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
9026 assert!((f - 12.0).abs() < 1e-12, "got {f}");
9027 // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
9028 let mut g = [0.0_f64; 2];
9029 grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
9030 assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
9031 assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
9032 // collect_vars reaches into the CSE body and finds {0, 1}.
9033 let mut vs = BTreeSet::new();
9034 p.obj_nonlinear.collect_vars(&mut vs);
9035 assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
9036 }
9037
9038 /// `min (x0 - 1)^2` with three suffix segments attached: an
9039 /// integer constraint-suffix (target=1, kind=1), an integer var-
9040 /// suffix (target=0, kind=0), and a real var-suffix (target=0,
9041 /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
9042 /// `<idx> <value>` lines.
9043 const WITH_SUFFIXES: &str = "g3 0 1 0
90441 0 1 0 0
90450 1
90460 0
90470 1 0
90480 0 0 1
90490 0 0 0 0
90500 0
90510 0
90520 0 0 0 0
9053O0 0
9054o5
9055o1
9056v0
9057n1
9058n2
9059b
90603
9061S0 1 sens_state_1
90620 7
9063S4 1 sens_state_value_1
90640 4.5
9065";
9066
9067 #[test]
9068 fn parses_var_int_and_var_real_suffixes() {
9069 let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
9070 // Integer var-suffix: dense length 1, slot 0 = 7.
9071 let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
9072 assert_eq!(v.as_slice(), &[7]);
9073 // Real var-suffix: dense length 1, slot 0 = 4.5.
9074 let r = p
9075 .suffixes
9076 .var_real
9077 .get("sens_state_value_1")
9078 .expect("var_real");
9079 assert_eq!(r.len(), 1);
9080 assert!((r[0] - 4.5).abs() < 1e-12);
9081 // Other suffix slots stay empty.
9082 assert!(p.suffixes.con_int.is_empty());
9083 assert!(p.suffixes.con_real.is_empty());
9084 }
9085
9086 /// Two-variable + two-constraint problem with a constraint-level
9087 /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
9088 const WITH_CON_SUFFIX: &str = "g3 0 1 0
90892 2 1 0 0
90900 0
90910 0
90920 2 0
90930 0 0 1
90940 0 0 0 0
90954 0
90960 0
90970 0 0 0 0 0
9098C0
9099n0
9100C1
9101n0
9102O0 0
9103n0
9104r
91054 0.0
91064 0.0
9107b
91083
91093
9110k1
91110
9112J0 2
91130 1
91141 1
9115J1 2
91160 1
91171 -1
9118S1 2 sens_init_constr
91190 1
91201 2
9121";
9122
9123 #[test]
9124 fn parses_con_int_suffix() {
9125 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9126 let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
9127 // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
9128 assert_eq!(s.as_slice(), &[1, 2]);
9129 }
9130
9131 // ---- gh#785: a truncated file is rejected, not silently defaulted ----
9132 //
9133 // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
9134 // segment boundary is exactly the failure the issue reports: an
9135 // interrupted write. Each cut loses a different first segment and each
9136 // is caught by a different check, so all three are asserted — and the
9137 // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
9138 // what keeps these from passing against a parser that rejects
9139 // everything.
9140
9141 /// Everything from `at` (a segment header line) onward is gone.
9142 fn truncate_before(txt: &str, at: &str) -> String {
9143 let cut = txt
9144 .find(at)
9145 .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
9146 txt[..cut].to_string()
9147 }
9148
9149 #[test]
9150 fn truncation_before_the_row_bounds_is_a_parse_error() {
9151 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
9152 .expect_err("truncated file must not parse");
9153 assert!(
9154 err.contains("`r` (constraint-bounds) segment"),
9155 "error should name the missing segment: {err}"
9156 );
9157 }
9158
9159 #[test]
9160 fn truncation_before_the_variable_bounds_is_a_parse_error() {
9161 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
9162 .expect_err("truncated file must not parse");
9163 assert!(
9164 err.contains("`b` (variable-bounds) segment"),
9165 "error should name the missing segment: {err}"
9166 );
9167 }
9168
9169 /// The cut that leaves every bound in place and takes only the
9170 /// coefficients. Neither presence check can see it; the declared-vs-
9171 /// parsed nonzero count is the whole of the evidence.
9172 #[test]
9173 fn truncation_before_the_jacobian_is_a_parse_error() {
9174 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
9175 .expect_err("truncated file must not parse");
9176 assert!(
9177 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
9178 "error should report the mismatch: {err}"
9179 );
9180 }
9181
9182 /// The mismatch is an equality, not a floor: a file supplying *more*
9183 /// entries than it declares is as corrupt as one supplying fewer, and a
9184 /// `>=` check would wave it through.
9185 #[test]
9186 fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
9187 let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
9188 let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
9189 assert!(
9190 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
9191 "error should report the mismatch: {err}"
9192 );
9193 }
9194
9195 /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
9196 /// (n = m = 2) and hand back everything the engine would see.
9197 fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
9198 let mut obj = 1.0;
9199 let mut use_x = false;
9200 let mut x = vec![0.0; 2];
9201 let mut use_g = false;
9202 let mut g = vec![0.0; 2];
9203 let ok = tnlp.get_scaling_parameters(ScalingRequest {
9204 obj_scaling: &mut obj,
9205 use_x_scaling: &mut use_x,
9206 x_scaling: &mut x,
9207 use_g_scaling: &mut use_g,
9208 g_scaling: &mut g,
9209 });
9210 (ok, obj, use_x, x, use_g, g)
9211 }
9212
9213 /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
9214 /// the objective (`S6`) and one constraint (`S5`) reaches the
9215 /// engine's `user-scaling` pathway. The untagged second row is
9216 /// unscaled — its AMPL suffix default is 0, which is not a usable
9217 /// scale factor and reads as "not tagged".
9218 #[test]
9219 fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
9220 let nl = WITH_CON_SUFFIX.to_string()
9221 + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
9222 let p = parse_nl_text(&nl).expect("parse");
9223 let mut tnlp = NlTnlp::new(p);
9224 let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
9225 assert!(ok, "a tagged model must supply scaling");
9226 assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
9227 assert!(use_g);
9228 assert_eq!(g, vec![10.0, 1.0]);
9229 assert!(!use_x, "no variable suffix was declared");
9230 }
9231
9232 /// Variable-level `scaling_factor` entries are passed through, not
9233 /// dropped on the floor: `OrigIpoptNlp` does not model them and
9234 /// refuses the solve, which is the whole point of gh#483.
9235 #[test]
9236 fn scaling_factor_suffix_forwards_variable_factors() {
9237 let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
9238 let p = parse_nl_text(&nl).expect("parse");
9239 let mut tnlp = NlTnlp::new(p);
9240 let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
9241 assert!(ok);
9242 assert!(use_x, "variable factors must reach the engine");
9243 assert_eq!(x, vec![1.0, 3.0]);
9244 }
9245
9246 /// gh #703: with curvature-based scaling switched on, the computed
9247 /// factors are the base and a `scaling_factor` suffix the model
9248 /// actually carries wins **component by component** — an explicit
9249 /// factor from the modeller beats one inferred from the coefficients,
9250 /// and the components they did not tag keep the inferred one rather
9251 /// than snapping back to 1.
9252 #[test]
9253 fn a_user_suffix_overrides_the_computed_factor_component_wise() {
9254 let baseline = {
9255 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9256 let mut t = NlTnlp::new(p);
9257 assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
9258 scaling_of(&mut t).5
9259 };
9260 assert!(
9261 baseline.iter().all(|v| *v > 0.0),
9262 "curvature scaling should produce usable row factors, got {baseline:?}"
9263 );
9264
9265 // Tag row 0 only. AMPL's untagged default is 0, which must read as
9266 // "not tagged" and leave row 1 on the computed factor.
9267 let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
9268 let p = parse_nl_text(&nl).expect("parse");
9269 let mut t = NlTnlp::new(p);
9270 assert!(t.enable_curvature_scaling());
9271 let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
9272 assert!(ok);
9273 assert!(use_g && use_x);
9274 assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
9275 assert_eq!(
9276 g[1], baseline[1],
9277 "the untagged row keeps the computed one, not 1.0"
9278 );
9279 }
9280
9281 /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
9282 /// same answer the default `TNLP` impl gives, so `user-scaling`
9283 /// falls back to no scaling instead of a bogus all-zero vector.
9284 #[test]
9285 fn no_scaling_factor_suffix_declines() {
9286 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9287 let mut tnlp = NlTnlp::new(p);
9288 let (ok, ..) = scaling_of(&mut tnlp);
9289 assert!(!ok);
9290 }
9291
9292 #[test]
9293 fn rejects_suffix_with_out_of_range_index() {
9294 let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
9295 let err = parse_nl_text(&bad).expect_err("must reject");
9296 assert!(
9297 err.contains("out of range"),
9298 "expected out-of-range error, got: {err}"
9299 );
9300 }
9301
9302 #[test]
9303 fn tnlp_round_trip_solves() {
9304 let p = parse_nl_text(SIMPLE).expect("parse");
9305 let mut tnlp = NlTnlp::new(p);
9306 let info = tnlp.get_nlp_info().unwrap();
9307 assert_eq!(info.n, 2);
9308 assert_eq!(info.m, 0);
9309 let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
9310 assert!((f0 - 5.0).abs() < 1e-12);
9311 let mut g = [0.0_f64; 2];
9312 tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
9313 // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
9314 assert!((g[0] - (-2.0)).abs() < 1e-12);
9315 assert!((g[1] - (-4.0)).abs() < 1e-12);
9316 }
9317
9318 // ---- Sibling `.col` / `.row` name-file capture --------------------
9319 //
9320 // Names let diagnostics name the offending equation instead of "row 3"
9321 // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
9322 // the read path and the documented fallback-to-empty behavior.
9323
9324 use pounce_nlp::expression_provider::ExpressionProvider;
9325 use std::sync::atomic::{AtomicUsize, Ordering};
9326
9327 /// Unique scratch dir for one test (no `tempfile` dev-dep available).
9328 fn scratch_dir(tag: &str) -> std::path::PathBuf {
9329 static N: AtomicUsize = AtomicUsize::new(0);
9330 let seq = N.fetch_add(1, Ordering::Relaxed);
9331 let dir = std::env::temp_dir().join(format!(
9332 "pounce_nlnames_{}_{}_{}",
9333 std::process::id(),
9334 tag,
9335 seq
9336 ));
9337 std::fs::create_dir_all(&dir).expect("create scratch dir");
9338 dir
9339 }
9340
9341 #[test]
9342 fn read_name_file_reads_in_order() {
9343 let dir = scratch_dir("col_order");
9344 let p = dir.join("m.col");
9345 std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
9346 assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
9347 }
9348
9349 #[test]
9350 fn read_name_file_truncates_extra_lines() {
9351 // `.row` conventionally appends the objective name after the m
9352 // constraint names; `.take(expected)` must drop it so names stay
9353 // 1:1 with `g`.
9354 let dir = scratch_dir("row_obj");
9355 let p = dir.join("m.row");
9356 std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
9357 assert_eq!(
9358 read_name_file(&p, 2),
9359 vec!["mass_balance", "energy_balance"]
9360 );
9361 }
9362
9363 #[test]
9364 fn read_name_file_empty_on_short_or_missing() {
9365 let dir = scratch_dir("short");
9366 let short = dir.join("m.col");
9367 std::fs::write(&short, "only_one\n").unwrap();
9368 // Fewer lines than expected ⇒ empty (never a partial mapping).
9369 assert!(read_name_file(&short, 3).is_empty());
9370 // Missing file ⇒ empty, no error.
9371 assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
9372 }
9373
9374 #[test]
9375 fn read_nl_file_captures_sibling_names() {
9376 // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
9377 // names ride through onto the TNLP's ExpressionProvider.
9378 let dir = scratch_dir("sibling");
9379 let nl = dir.join("m.nl");
9380 std::fs::write(&nl, SIMPLE).unwrap();
9381 std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
9382
9383 let prob = read_nl_file(&nl).expect("parse + name capture");
9384 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9385 assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
9386
9387 let tnlp = NlTnlp::new(prob);
9388 assert_eq!(tnlp.variable_name(0), Some("alpha"));
9389 assert_eq!(tnlp.variable_name(1), Some("beta"));
9390 assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
9391 }
9392
9393 #[test]
9394 fn read_nl_file_without_names_yields_empty() {
9395 let dir = scratch_dir("noname");
9396 let nl = dir.join("m.nl");
9397 std::fs::write(&nl, SIMPLE).unwrap();
9398 let prob = read_nl_file(&nl).expect("parse");
9399 assert!(prob.var_names.is_empty());
9400 assert!(prob.con_names.is_empty());
9401 let tnlp = NlTnlp::new(prob);
9402 assert_eq!(tnlp.variable_name(0), None);
9403 }
9404
9405 #[test]
9406 fn read_nl_file_resolves_extensionless_ampl_stub() {
9407 // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
9408 // the `.nl` extension; the solver must read `mystub.nl`. Code
9409 // review 2026-06 item M15.
9410 let dir = scratch_dir("stub");
9411 std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
9412 // Pass the extensionless stub — the file `mystub` does not exist.
9413 let stub = dir.join("mystub");
9414 assert!(!stub.exists(), "stub must be extensionless / absent");
9415 let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
9416 assert_eq!(prob.n, 2);
9417 assert_eq!(prob.m, 0);
9418
9419 // Sibling name files are still found off the resolved stem.
9420 std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
9421 let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
9422 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9423 }
9424
9425 #[test]
9426 fn read_nl_file_prefers_exact_path_over_nl_sibling() {
9427 // An existing path is read verbatim — the `.nl` fallback only
9428 // kicks in when the literal path is missing, so a caller passing a
9429 // real file is never silently redirected to a `<file>.nl` sibling.
9430 let dir = scratch_dir("exact");
9431 // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
9432 std::fs::write(dir.join("data"), SIMPLE).unwrap();
9433 std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
9434 let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
9435 assert_eq!(prob.n, 2);
9436 }
9437
9438 #[test]
9439 fn append_extension_appends_rather_than_replaces() {
9440 use std::path::Path;
9441 assert_eq!(
9442 append_extension(Path::new("mystub"), "nl"),
9443 Path::new("mystub.nl")
9444 );
9445 // A stub that itself contains a dot keeps its stem (AMPL names it
9446 // `my.model.nl`, not `my.nl`).
9447 assert_eq!(
9448 append_extension(Path::new("my.model"), "nl"),
9449 Path::new("my.model.nl")
9450 );
9451 }
9452
9453 // ---- equation rendering (`print equation`) ----
9454
9455 fn names(v: &[&str]) -> Vec<String> {
9456 v.iter().map(|s| s.to_string()).collect()
9457 }
9458
9459 #[test]
9460 fn render_uses_variable_names_when_present() {
9461 let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9462 assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
9463 // Falls back to x[i] when names are absent.
9464 assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
9465 }
9466
9467 #[test]
9468 fn render_parenthesizes_by_precedence() {
9469 // (x0 + x1) * x2 must keep the parens around the sum.
9470 let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9471 let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
9472 assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
9473
9474 // x0 + x1 * x2 needs no parens (mul binds tighter).
9475 let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9476 let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
9477 assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
9478 }
9479
9480 #[test]
9481 fn render_subtraction_right_assoc_parens() {
9482 // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
9483 let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9484 let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
9485 assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
9486 }
9487
9488 #[test]
9489 fn render_functions_and_pow() {
9490 let sq = Expr::Binary(
9491 BinOp::Pow,
9492 Box::new(Expr::Var(0)),
9493 Box::new(Expr::Const(2.0)),
9494 );
9495 let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
9496 assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
9497 }
9498
9499 #[test]
9500 fn render_linear_signs_are_tidy() {
9501 // 1*a - 2*b + c (coef +1 omits the multiplier).
9502 let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
9503 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
9504 }
9505
9506 #[test]
9507 fn render_linear_skips_zero_coefficients() {
9508 // A 0 coefficient (a variable present only in the nonlinear part)
9509 // is dropped, not rendered as `0*x`.
9510 let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
9511 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
9512 // Leading term zero ⇒ the first emitted term still has no ` + `.
9513 let lin = vec![(0usize, 0.0), (1, 2.0)];
9514 assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
9515 }
9516
9517 #[test]
9518 fn render_sum_folds_negative_terms() {
9519 // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
9520 let sq = |i| {
9521 Expr::Binary(
9522 BinOp::Pow,
9523 Box::new(Expr::Var(i)),
9524 Box::new(Expr::Const(2.0)),
9525 )
9526 };
9527 let neg = |i| {
9528 Expr::Binary(
9529 BinOp::Mul,
9530 Box::new(Expr::Const(-1.0)),
9531 Box::new(Expr::Var(i)),
9532 )
9533 };
9534 let e = Expr::Sum(vec![
9535 sq(0),
9536 neg(1),
9537 Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
9538 ]);
9539 assert_eq!(
9540 render_expr(&e, &names(&["a", "b", "c"]), &[]),
9541 "a^2 - 1*b - c"
9542 );
9543 }
9544
9545 #[test]
9546 fn render_constraint_equation_forms() {
9547 // Build a 2-constraint problem by hand: an equality and a range.
9548 let mut prob = parse_nl_text(SIMPLE).unwrap();
9549 // Overwrite to a known small shape: 1 var, 2 cons.
9550 prob.n = 2;
9551 prob.m = 2;
9552 prob.var_names = names(&["mass_in", "mass_out"]);
9553 prob.con_names = names(&["balance", "window"]);
9554 prob.con_linear = vec![
9555 vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
9556 vec![(0, 1.0)], // mass_in
9557 ];
9558 prob.con_nonlinear = vec![
9559 NlBody::Tree(Expr::Const(0.0)),
9560 NlBody::Tree(Expr::Const(0.0)),
9561 ];
9562 prob.g_l = vec![0.0, 0.0];
9563 prob.g_u = vec![0.0, 500.0];
9564
9565 assert_eq!(
9566 render_constraint_equation(&prob, 0),
9567 "mass_in - mass_out = 0"
9568 );
9569 assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
9570
9571 let all = render_all_constraint_equations(&prob);
9572 assert_eq!(all.len(), 2);
9573 assert_eq!(all[1], "0 <= mass_in <= 500");
9574 }
9575
9576 #[test]
9577 fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
9578 let mut prob = parse_nl_text(SIMPLE).unwrap();
9579 prob.n = 3;
9580 prob.m = 2;
9581 // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
9582 // Row 1: linear in x2 only → support {2}.
9583 prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
9584 prob.con_nonlinear = vec![
9585 NlBody::Tree(Expr::Binary(
9586 BinOp::Mul,
9587 Box::new(Expr::Var(0)),
9588 Box::new(Expr::Var(2)),
9589 )),
9590 NlBody::Tree(Expr::Const(0.0)),
9591 ];
9592 prob.g_l = vec![0.0, 0.0];
9593 prob.g_u = vec![0.0, 0.0];
9594
9595 let (irow, jcol) = constraint_jacobian_sparsity(&prob);
9596 // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
9597 assert_eq!(irow, vec![0, 0, 0, 1]);
9598 assert_eq!(jcol, vec![0, 1, 2, 2]);
9599 }
9600
9601 #[test]
9602 fn funcall_string_arg_with_hash_is_not_truncated() {
9603 // Code review L31: an AMPL string argument is a Hollerith literal
9604 // `h<len>:<chars>` whose content is exactly <len> bytes and may
9605 // legitimately contain '#' (e.g. a parameters-directory path). The
9606 // old parser ran strip_comment() over the line first, truncating
9607 // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
9608 let mut p = Parser::new("h3:a#b\n", false);
9609 match p.parse_funcall_arg().expect("parse hollerith arg") {
9610 FuncallArg::Str(s) => assert_eq!(s, "a#b"),
9611 other => panic!("expected Str, got {other:?}"),
9612 }
9613 }
9614
9615 #[test]
9616 fn funcall_string_arg_honors_declared_length() {
9617 // The declared `<len>` is authoritative: exactly that many bytes
9618 // after the ':' form the string; trailing content (here a real
9619 // ` # comment`) is not part of it.
9620 let mut p = Parser::new("h3:abc # trailing comment\n", false);
9621 match p.parse_funcall_arg().expect("parse hollerith arg") {
9622 FuncallArg::Str(s) => assert_eq!(s, "abc"),
9623 other => panic!("expected Str, got {other:?}"),
9624 }
9625 }
9626
9627 // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
9628 //
9629 // AMPL emits these in place of the general `o5` (OPPOW) when one operand
9630 // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
9631 // negative-base-safe constant-power lowering applies. The eval points below
9632 // are chosen to pin down BOTH the arity and the operand order: a swapped
9633 // `base`/`exp` (or treating `o82` as a different unary op) gives a
9634 // different number at these points, so each assertion is discriminating.
9635
9636 /// Parse a single expression `expr_src` with `n` variables in scope,
9637 /// driving the real `parse_opcode` path through `parse_expr`.
9638 fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
9639 let mut p = Parser::new(expr_src, false);
9640 p.n = n;
9641 p.parse_expr().expect("parse expression")
9642 }
9643
9644 #[test]
9645 fn opcode_o82_square_is_unary_pow_of_two() {
9646 // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
9647 let e = parse_one_expr(1, "o82\nv0\n");
9648 match &e {
9649 Expr::Binary(BinOp::Pow, base, exp) => {
9650 assert!(matches!(**base, Expr::Var(0)));
9651 match **exp {
9652 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
9653 ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
9654 }
9655 }
9656 other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
9657 }
9658 // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
9659 // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
9660 assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
9661 assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
9662 // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
9663 let mut g = [0.0_f64; 1];
9664 grad_expr(&e, &[3.0], 1.0, &mut g);
9665 assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
9666 g[0] = 0.0;
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 }
9670
9671 #[test]
9672 fn opcode_o81_const_exponent_is_base_pow_const() {
9673 // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
9674 let e = parse_one_expr(1, "o81\nv0\nn3\n");
9675 match &e {
9676 Expr::Binary(BinOp::Pow, base, exp) => {
9677 assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
9678 match **exp {
9679 Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
9680 ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
9681 }
9682 }
9683 other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
9684 }
9685 // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
9686 assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
9687 // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
9688 // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
9689 assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
9690 // gradient d/dx x^3 = 3x^2 = 12 at x=2.
9691 let mut g = [0.0_f64; 1];
9692 grad_expr(&e, &[2.0], 1.0, &mut g);
9693 assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
9694 }
9695
9696 #[test]
9697 fn opcode_o83_const_base_is_const_pow_exp() {
9698 // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
9699 let e = parse_one_expr(1, "o83\nn2\nv0\n");
9700 match &e {
9701 Expr::Binary(BinOp::Pow, base, exp) => {
9702 match **base {
9703 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
9704 ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
9705 }
9706 assert!(
9707 matches!(**exp, Expr::Var(0)),
9708 "exponent must be the variable"
9709 );
9710 }
9711 other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
9712 }
9713 // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
9714 assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
9715 assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
9716 // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
9717 let mut g = [0.0_f64; 1];
9718 grad_expr(&e, &[3.0], 1.0, &mut g);
9719 assert!(
9720 (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
9721 "grad at 3 = {} (want {})",
9722 g[0],
9723 8.0 * 2.0_f64.ln()
9724 );
9725 }
9726
9727 #[test]
9728 fn power_specializations_agree_with_general_o5() {
9729 // Where both are defined, o81/o82/o83 must be numerically identical to
9730 // the general `o5` pow on the same operands — they are only routing
9731 // hints, not different math.
9732 let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
9733 let o82 = parse_one_expr(1, "o82\nv0\n");
9734 let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
9735 let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
9736 let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
9737 let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
9738 for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
9739 assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
9740 assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
9741 // 2^x is real for all x; compare across the same points.
9742 assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
9743 }
9744 }
9745
9746 #[test]
9747 fn power_opcodes_round_trip_through_parse_nl_text() {
9748 // End-to-end through the public entry point: `min x0^2 + x1^2` written
9749 // with o82 (square) parses and evaluates like its o5 twin. Reuses the
9750 // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
9751 let nl = SIMPLE.replace(
9752 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9753 "o0\no82\nv0\no82\nv1\n",
9754 );
9755 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9756 let p = parse_nl_text(&nl).expect("parse o82 objective");
9757 // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
9758 assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
9759 assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
9760 }
9761
9762 #[test]
9763 fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
9764 // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
9765 // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
9766 // (the negative-base-safe path) rather than a generic `powf`. The check
9767 // at a NEGATIVE base is the one that would break if o81 wrongly routed
9768 // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
9769 let nl = SIMPLE.replace(
9770 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9771 "o0\no81\nv0\nn3\no81\nv1\nn3\n",
9772 );
9773 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9774 let p = parse_nl_text(&nl).expect("parse o81 objective");
9775 let mut tnlp = NlTnlp::new(p);
9776 tnlp.get_nlp_info().unwrap();
9777 // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
9778 let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
9779 assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
9780 // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
9781 let mut g = [0.0_f64; 2];
9782 assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
9783 assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
9784 assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
9785 }
9786
9787 // ---- Shared-CSE constraint tape (issue #476) ----------------------
9788
9789 /// Three constraints over one `V` segment (a `.nl` *defined variable*),
9790 /// `V3 = 2*x0 + 3*x1`, referenced by all three:
9791 /// C0: V3^2 C1: V3^3 + x2 C2: V3*x2
9792 /// `{BODY2}` is a substitution point so a variant can drop an opcode the
9793 /// hybrid path rejects into C2.
9794 const SHARED_CSE: &str = "g3 1 1 0
9795 3 3 1 0 0
9796 3 0
9797 0 0
9798 3 0 0
9799 0 0 0 1
9800 0 0 0 0 0
9801 8 3
9802 0 0
9803 0 1 0 0 0
9804V3 2 0
98050 2.0
98061 3.0
9807n0
9808C0
9809o5
9810v3
9811n2
9812C1
9813o0
9814o5
9815v3
9816n3
9817v2
9818C2
9819{BODY2}
9820O0 0
9821n0
9822r
98232 0
98242 0
98252 0
9826b
98273
98283
98293
9830k2
98313
98326
9833J0 2
98340 0
98351 0
9836J1 3
98370 0
98381 0
98392 0
9840J2 3
98410 0
98421 0
98432 0
9844G0 3
98450 1.0
98461 1.0
98472 1.0
9848";
9849
9850 fn shared_cse_nl(body2: &str) -> String {
9851 SHARED_CSE.replace("{BODY2}", body2)
9852 }
9853
9854 /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
9855 /// per-summand tapes return — it is a different traversal of the same
9856 /// DAG, not a different derivative. Forced on here regardless of
9857 /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
9858 /// the size heuristic that decides when to use it.
9859 #[test]
9860 fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
9861 let nl = shared_cse_nl("o2\nv3\nv2");
9862 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9863
9864 let mut hybrid = NlTnlp::new(p.clone());
9865 let info = hybrid.get_nlp_info().unwrap();
9866 let nnz = info.nnz_jac_g as usize;
9867 hybrid
9868 .con_hybrid
9869 .as_mut()
9870 .expect("CSE shared by 3 constraints must build the hybrid tape")
9871 .use_for_jac = true;
9872
9873 let mut flat = NlTnlp::new(p);
9874 flat.get_nlp_info().unwrap();
9875 flat.con_hybrid = None;
9876
9877 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9878 let mut jh = vec![0.0_f64; nnz];
9879 let mut jf = vec![0.0_f64; nnz];
9880 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9881 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9882 assert_eq!(
9883 jh, jf,
9884 "hybrid Jacobian differs from the flat tape at {x:?}"
9885 );
9886
9887 // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
9888 let s = 2.0 * x[0] + 3.0 * x[1];
9889 let want = [
9890 4.0 * s,
9891 6.0 * s,
9892 6.0 * s * s,
9893 9.0 * s * s,
9894 1.0,
9895 2.0 * x[2],
9896 3.0 * x[2],
9897 s,
9898 ];
9899 assert_eq!(nnz, want.len());
9900 for k in 0..nnz {
9901 assert!(
9902 (jh[k] - want[k]).abs() < 1e-9,
9903 "entry {k} at {x:?}: got {}, want {}",
9904 jh[k],
9905 want[k]
9906 );
9907 }
9908 }
9909 }
9910
9911 /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
9912 /// CSE `S = body(x0, x1)` (the caller passes the body's expression
9913 /// text, which must reference exactly `v0` and `v1`). A deep shared
9914 /// body over few local ops per row is the regime the op-ratio gates
9915 /// are meant to catch: no repository fixture has a CSE shared across
9916 /// constraints at all, so without this the gate-on paths have no
9917 /// natural coverage.
9918 fn shared_body_chain_nl(m: usize, body: &str) -> String {
9919 let n = m + 2;
9920 let nzc = 3 * m;
9921 let mut s = String::new();
9922 s.push_str("g3 1 1 0\n");
9923 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9924 s.push_str(&format!(" {m} 0\n 0 0\n"));
9925 s.push_str(&format!(" {n} 0 0\n"));
9926 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9927 s.push_str(&format!(" {nzc} {n}\n"));
9928 s.push_str(" 0 0\n 0 1 0 0 0\n");
9929 // The shared body.
9930 s.push_str(&format!("V{n} 0 0\n"));
9931 s.push_str(body);
9932 // Rows: S * x_{i+2}.
9933 for i in 0..m {
9934 s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
9935 }
9936 s.push_str("O0 0\nn0\n");
9937 s.push_str(&format!("x{n}\n"));
9938 for j in 0..n {
9939 s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
9940 }
9941 s.push_str("r\n");
9942 for _ in 0..m {
9943 s.push_str("2 0\n");
9944 }
9945 s.push_str("b\n");
9946 for _ in 0..n {
9947 s.push_str("3\n");
9948 }
9949 // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
9950 s.push_str(&format!("k{}\n", n - 1));
9951 let mut acc = 0;
9952 for j in 0..n - 1 {
9953 acc += if j < 2 { m } else { 1 };
9954 s.push_str(&format!("{acc}\n"));
9955 }
9956 for i in 0..m {
9957 s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
9958 }
9959 s.push_str(&format!("G0 {n}\n"));
9960 for j in 0..n {
9961 s.push_str(&format!("{j} 0.0\n"));
9962 }
9963 s
9964 }
9965
9966 /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
9967 /// The forward value saturates to `inf` past depth ≈ 5, which the
9968 /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
9969 /// bounded variant below instead, whose second-order terms would
9970 /// otherwise mix `inf`s of both signs into `NaN`.
9971 fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
9972 let mut body = String::new();
9973 for _ in 0..depth {
9974 body.push_str("o44\n");
9975 }
9976 body.push_str("o2\nn0.01\no0\nv0\nv1\n");
9977 shared_body_chain_nl(m, &body)
9978 }
9979
9980 /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
9981 /// (x0 + x1))` — mathematically the identity chain, so the value
9982 /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
9983 /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
9984 /// through both second-order sweeps. `2 * pairs` body ops drive the
9985 /// flat/shared op ratio as high as the Hessian gate tests need.
9986 fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
9987 let mut body = String::new();
9988 for _ in 0..pairs {
9989 body.push_str("o43\no44\n");
9990 }
9991 body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
9992 shared_body_chain_nl(m, &body)
9993 }
9994
9995 /// With a deep shared body the gate turns itself on, and the path it
9996 /// turns on must still agree with the flat tapes exactly.
9997 #[test]
9998 fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
9999 let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
10000
10001 let mut hybrid = NlTnlp::new(p.clone());
10002 let info = hybrid.get_nlp_info().unwrap();
10003 let nnz = info.nnz_jac_g as usize;
10004 assert!(
10005 hybrid
10006 .con_hybrid
10007 .as_ref()
10008 .expect("shared CSE must build the hybrid tape")
10009 .use_for_jac,
10010 "a 40-deep body shared by 16 rows is well past the op-ratio gate"
10011 );
10012
10013 let mut flat = NlTnlp::new(p);
10014 flat.get_nlp_info().unwrap();
10015 flat.con_hybrid = None;
10016
10017 for scale in [1.0_f64, -0.7, 2.5] {
10018 let x: Vec<f64> = (0..info.n as usize)
10019 .map(|j| scale * (0.2 + 0.03 * j as f64))
10020 .collect();
10021 let mut jh = vec![0.0_f64; nnz];
10022 let mut jf = vec![0.0_f64; nnz];
10023 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
10024 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
10025 assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
10026 assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
10027 }
10028 }
10029
10030 /// The Jacobian gate is off for a model whose shared bodies are small,
10031 /// because there the hybrid traversal's per-op overhead outweighs the
10032 /// halved forward sweep. `eval_g` still takes the hybrid path — that
10033 /// one is a win at any ratio.
10034 #[test]
10035 fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
10036 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10037 let mut t = NlTnlp::new(p);
10038 t.get_nlp_info().unwrap();
10039 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10040 assert!(
10041 !h.use_for_jac,
10042 "a 3-row model with a 2-term CSE is far below the op-ratio gate"
10043 );
10044 }
10045
10046 /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
10047 /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
10048 /// adjoints and running one prelude sweep reassociates floating-point
10049 /// products, so the two paths agree only to rounding on general inputs —
10050 /// but here every operation in both traversals is exact (dyadic values,
10051 /// small-integer coefficients, polynomial ops), so the results must be
10052 /// bit-identical, pinning the arithmetic itself and not just its
10053 /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
10054 /// path is covered independently of the size heuristic.
10055 #[test]
10056 fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
10057 let nl = shared_cse_nl("o2\nv3\nv2");
10058 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10059
10060 let mut hybrid = NlTnlp::new(p.clone());
10061 let info = hybrid.get_nlp_info().unwrap();
10062 let nnz = info.nnz_h_lag as usize;
10063 hybrid
10064 .con_hybrid
10065 .as_mut()
10066 .expect("CSE shared by 3 constraints must build the hybrid tape")
10067 .use_for_hess = true;
10068
10069 let mut flat = NlTnlp::new(p);
10070 flat.get_nlp_info().unwrap();
10071 flat.con_hybrid = None;
10072
10073 let pairs: Vec<(usize, usize)> = hybrid
10074 .h_irow
10075 .iter()
10076 .zip(&hybrid.h_jcol)
10077 .map(|(&i, &j)| (i as usize, j as usize))
10078 .collect();
10079
10080 // Two multiplier sets: one all-live, one with a dead row so the
10081 // λ == 0 skip is exercised on the hybrid path too.
10082 for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
10083 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10084 let mut hh = vec![0.0_f64; nnz];
10085 let mut hf = vec![0.0_f64; nnz];
10086 assert!(hybrid.eval_h(
10087 Some(&x),
10088 true,
10089 1.0,
10090 Some(&lam),
10091 true,
10092 SparsityRequest::Values { values: &mut hh }
10093 ));
10094 assert!(flat.eval_h(
10095 Some(&x),
10096 true,
10097 1.0,
10098 Some(&lam),
10099 true,
10100 SparsityRequest::Values { values: &mut hf }
10101 ));
10102 assert_eq!(
10103 hh, hf,
10104 "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
10105 );
10106
10107 // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
10108 // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
10109 // objective is constant, so the Lagrangian Hessian is
10110 // (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
10111 let s = 2.0 * x[0] + 3.0 * x[1];
10112 let q = 2.0 * lam[0] + 6.0 * s * lam[1];
10113 let dv = [2.0, 3.0, 0.0];
10114 for (k, &(i, j)) in pairs.iter().enumerate() {
10115 let mut want = q * dv[i] * dv[j];
10116 if i == 2 {
10117 want += lam[2] * dv[j];
10118 }
10119 if j == 2 {
10120 want += lam[2] * dv[i];
10121 }
10122 assert!(
10123 (hh[k] - want).abs() < 1e-9,
10124 "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
10125 hh[k]
10126 );
10127 }
10128 }
10129 }
10130 }
10131
10132 /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
10133 /// turns the Hessian gate on by itself, and the path it turns on must
10134 /// agree with the flat tapes. Not bitwise here: the shared prelude
10135 /// reverse sweep runs once over the `λ_k`-folded adjoints of all
10136 /// summands where the flat path runs per summand, and that
10137 /// reassociation moves transcendental results by rounding — so the bar
10138 /// is a relative few-ULP band, with the exact-arithmetic case pinned
10139 /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
10140 #[test]
10141 fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
10142 let m = 16;
10143 let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
10144
10145 let mut hybrid = NlTnlp::new(p.clone());
10146 let info = hybrid.get_nlp_info().unwrap();
10147 let nnz = info.nnz_h_lag as usize;
10148 assert!(
10149 hybrid
10150 .con_hybrid
10151 .as_ref()
10152 .expect("shared CSE must build the hybrid tape")
10153 .use_for_hess,
10154 "a 40-op body shared by 16 rows is well past the op-ratio gate"
10155 );
10156
10157 let mut flat = NlTnlp::new(p);
10158 flat.get_nlp_info().unwrap();
10159 flat.con_hybrid = None;
10160
10161 let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
10162 for scale in [1.0_f64, -0.7, 2.5] {
10163 let x: Vec<f64> = (0..info.n as usize)
10164 .map(|j| scale * (0.2 + 0.03 * j as f64))
10165 .collect();
10166 // Run the hybrid Jacobian first, the order a real solve
10167 // iteration uses. Its `gradient_summand` sweeps leave the
10168 // *Jacobian's* prelude adjoint arena dirty; the Hessian's
10169 // accumulators must be its own buffers with the all-zero-
10170 // between-colors invariant, or this seeds `eval_h` with a
10171 // stale row gradient (caught here).
10172 let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
10173 assert!(hybrid.eval_jac_g(
10174 Some(&x),
10175 true,
10176 SparsityRequest::Values { values: &mut jac }
10177 ));
10178 let mut hh = vec![0.0_f64; nnz];
10179 let mut hf = vec![0.0_f64; nnz];
10180 assert!(hybrid.eval_h(
10181 Some(&x),
10182 true,
10183 1.0,
10184 Some(&lam),
10185 true,
10186 SparsityRequest::Values { values: &mut hh }
10187 ));
10188 assert!(flat.eval_h(
10189 Some(&x),
10190 true,
10191 1.0,
10192 Some(&lam),
10193 true,
10194 SparsityRequest::Values { values: &mut hf }
10195 ));
10196 for k in 0..nnz {
10197 assert!(
10198 hh[k].is_finite() && hf[k].is_finite(),
10199 "non-finite Hessian entry {k} defeats the comparison"
10200 );
10201 let tol = 1e-12 * hf[k].abs().max(1.0);
10202 assert!(
10203 (hh[k] - hf[k]).abs() <= tol,
10204 "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
10205 hh[k],
10206 hf[k]
10207 );
10208 }
10209 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10210 }
10211 }
10212
10213 /// `.nl` text for two independent shared-CSE blocks of different widths:
10214 /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
10215 /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
10216 /// of the form `body * x_r`.
10217 ///
10218 /// The width difference is the point. A body summing `w` variables gives
10219 /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
10220 /// conflict and the coloring must spend `w` colors on them; the narrower
10221 /// block reuses the low colors. The surplus colors therefore belong to
10222 /// block A alone, and a per-color prelude walk over *both* bodies would
10223 /// be doing work for a body that color cannot reach.
10224 fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
10225 let nvars = wide + narrow;
10226 let m = 2 * rows;
10227 let n = nvars + m;
10228 // Block A rows touch `wide` body vars + 1 row var; block B rows
10229 // touch `narrow` + 1.
10230 let nzc = rows * (wide + 1) + rows * (narrow + 1);
10231 let mut s = String::new();
10232 s.push_str("g3 1 1 0\n");
10233 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
10234 s.push_str(&format!(" {m} 0\n 0 0\n"));
10235 s.push_str(&format!(" {n} 0 0\n"));
10236 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
10237 s.push_str(&format!(" {nzc} {n}\n"));
10238 s.push_str(" 0 0\n 0 2 0 0 0\n");
10239 // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
10240 // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
10241 for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
10242 s.push_str(&format!("V{} 0 0\n", n + b));
10243 s.push_str("o41\n");
10244 for _ in 0..count - 1 {
10245 s.push_str("o0\n");
10246 }
10247 for j in 0..*count {
10248 s.push_str(&format!("v{}\n", base + j));
10249 }
10250 }
10251 // Rows: body_b * x_{rowvar}.
10252 for i in 0..m {
10253 let b = i / rows;
10254 s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
10255 }
10256 s.push_str("O0 0\nn0\n");
10257 s.push_str(&format!("x{n}\n"));
10258 for j in 0..n {
10259 s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
10260 }
10261 s.push_str("r\n");
10262 for _ in 0..m {
10263 s.push_str("2 0\n");
10264 }
10265 s.push_str("b\n");
10266 for _ in 0..n {
10267 s.push_str("3\n");
10268 }
10269 // Cumulative Jacobian column counts for the first n-1 columns.
10270 s.push_str(&format!("k{}\n", n - 1));
10271 let mut acc = 0;
10272 for j in 0..n - 1 {
10273 acc += if j < nvars { rows } else { 1 };
10274 s.push_str(&format!("{acc}\n"));
10275 }
10276 for i in 0..m {
10277 let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
10278 s.push_str(&format!("J{i} {}\n", count + 1));
10279 let mut cols: Vec<usize> = (base..base + count).collect();
10280 cols.push(nvars + i);
10281 cols.sort_unstable();
10282 for c in cols {
10283 s.push_str(&format!("{c} 0.0\n"));
10284 }
10285 }
10286 s.push_str(&format!("G0 {n}\n"));
10287 for j in 0..n {
10288 s.push_str(&format!("{j} 0.0\n"));
10289 }
10290 s
10291 }
10292
10293 /// Both prelude sweeps run once per color, so iterating the whole prelude
10294 /// each time would cost `n_colors × |prelude|` where the op-ratio gate
10295 /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
10296 /// `eval_h` instead walks the union of that color's summands'
10297 /// `prelude_reach`.
10298 ///
10299 /// What this can and cannot pin is worth being exact about, because the
10300 /// change is pure performance: walking the whole prelude per color
10301 /// computes the *same* Hessian, so no assertion on output values can
10302 /// detect it, and a timing assertion would be flaky. So this asserts the
10303 /// two things that are checkable — that the table the sweeps iterate is
10304 /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
10305 /// where colors genuinely reach different bodies, and that each reach list
10306 /// is ascending and operand-closed, the invariants that make the narrowed
10307 /// walk safe — plus agreement with the flat tapes under narrowing.
10308 #[test]
10309 fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
10310 let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
10311 let mut hybrid = NlTnlp::new(p.clone());
10312 let info = hybrid.get_nlp_info().unwrap();
10313 let nnz = info.nnz_h_lag as usize;
10314 let m = info.m as usize;
10315
10316 {
10317 let h = hybrid
10318 .con_hybrid
10319 .as_mut()
10320 .expect("two shared CSE bodies must build the hybrid tape");
10321 h.use_for_hess = true;
10322
10323 let np = h.tape.n_prelude_ops();
10324 let n_colors = h.hess_color_reach_off.len() - 1;
10325 let total: usize = h.hess_color_reach.len();
10326 assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
10327 assert!(
10328 total < n_colors * np,
10329 "per-color reach must be strictly smaller than walking the whole \
10330 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
10331 n_colors * np
10332 );
10333 // Every reach list must be ascending and operand-closed, which is
10334 // what makes the narrowed walk safe.
10335 for c in 0..n_colors {
10336 let r =
10337 &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
10338 assert!(
10339 r.windows(2).all(|w| w[0] < w[1]),
10340 "color {c} reach is not strictly ascending"
10341 );
10342 let member: std::collections::HashSet<u32> = r.iter().copied().collect();
10343 for &i in r {
10344 let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
10345 for opnd in [a, b].into_iter().flatten() {
10346 assert!(
10347 member.contains(&(opnd as u32)),
10348 "color {c}: slot {i}'s operand {opnd} is missing from its reach"
10349 );
10350 }
10351 }
10352 }
10353 }
10354
10355 let mut flat = NlTnlp::new(p);
10356 flat.get_nlp_info().unwrap();
10357 flat.con_hybrid = None;
10358
10359 let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
10360 for scale in [1.0_f64, -0.6] {
10361 let x: Vec<f64> = (0..info.n as usize)
10362 .map(|j| scale * (0.15 + 0.02 * j as f64))
10363 .collect();
10364 let mut hh = vec![0.0_f64; nnz];
10365 let mut hf = vec![0.0_f64; nnz];
10366 assert!(hybrid.eval_h(
10367 Some(&x),
10368 true,
10369 1.0,
10370 Some(&lam),
10371 true,
10372 SparsityRequest::Values { values: &mut hh }
10373 ));
10374 assert!(flat.eval_h(
10375 Some(&x),
10376 true,
10377 1.0,
10378 Some(&lam),
10379 true,
10380 SparsityRequest::Values { values: &mut hf }
10381 ));
10382 for k in 0..nnz {
10383 let tol = 1e-12 * hf[k].abs().max(1.0);
10384 assert!(
10385 (hh[k] - hf[k]).abs() <= tol,
10386 "narrowed-reach Hessian entry {k} at scale {scale}: \
10387 hybrid {} vs flat {}",
10388 hh[k],
10389 hf[k]
10390 );
10391 }
10392 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10393 }
10394 }
10395
10396 /// The Hessian gate is off for a model whose shared bodies are small —
10397 /// below the ratio where the shared prelude sweeps pay for the hybrid
10398 /// traversal's per-op overhead — leaving `eval_h` on the flat path
10399 /// (which the gate keeps bit-identical for such models by definition).
10400 #[test]
10401 fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
10402 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10403 let mut t = NlTnlp::new(p);
10404 t.get_nlp_info().unwrap();
10405 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10406 assert!(
10407 !h.use_for_hess,
10408 "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
10409 );
10410 }
10411
10412 /// A CSE referenced from several constraints is evaluated once per
10413 /// `eval_g` via the shared prelude instead of once per reference. The
10414 /// values must be bit-identical to the flat per-summand `Tape` path,
10415 /// which is what makes the optimization safe to apply unconditionally.
10416 #[test]
10417 fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
10418 let nl = shared_cse_nl("o2\nv3\nv2");
10419 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10420 let mut hybrid = NlTnlp::new(p.clone());
10421 hybrid.get_nlp_info().unwrap();
10422 let h = hybrid
10423 .con_hybrid
10424 .as_ref()
10425 .expect("CSE shared by 3 constraints must take the hybrid path");
10426 assert!(
10427 h.tape.n_prelude_ops() > 0,
10428 "shared CSE body must land in the prelude"
10429 );
10430
10431 // Same model with the hybrid path switched off: the reference.
10432 let mut flat = NlTnlp::new(p);
10433 flat.get_nlp_info().unwrap();
10434 flat.con_hybrid = None;
10435
10436 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10437 let mut gh = [0.0_f64; 3];
10438 let mut gf = [0.0_f64; 3];
10439 assert!(hybrid.eval_g(&x, true, &mut gh));
10440 assert!(flat.eval_g(&x, true, &mut gf));
10441 // V3 = 2 x0 + 3 x1.
10442 let s = 2.0 * x[0] + 3.0 * x[1];
10443 let want = [s * s, s * s * s + x[2], s * x[2]];
10444 for i in 0..3 {
10445 assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
10446 assert!(
10447 (gh[i] - want[i]).abs() < 1e-9,
10448 "row {i}: got {}, want {}",
10449 gh[i],
10450 want[i]
10451 );
10452 }
10453 }
10454 }
10455
10456 /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
10457 /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
10458 /// take that path after `hybrid_supported` clears the model. Here a
10459 /// min-list in one constraint has to disable it for the whole block —
10460 /// falling back, not panicking.
10461 #[test]
10462 fn unsupported_opcode_falls_back_to_the_flat_tape() {
10463 let nl = shared_cse_nl("o11\n2\nv3\nv2");
10464 let p = parse_nl_text(&nl).expect("parse min-list model");
10465 let mut tnlp = NlTnlp::new(p);
10466 tnlp.get_nlp_info().unwrap();
10467 assert!(
10468 tnlp.con_hybrid.is_none(),
10469 "a min-list anywhere in the constraint block must disable the hybrid path"
10470 );
10471 let mut g = [0.0_f64; 3];
10472 assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
10473 let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
10474 assert!((g[0] - s * s).abs() < 1e-9);
10475 assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
10476 }
10477
10478 // ---- In-memory construction + HVP (issue #469) --------------------
10479
10480 fn v(i: usize) -> Expr {
10481 Expr::Var(i)
10482 }
10483
10484 fn c(x: Number) -> Expr {
10485 Expr::Const(x)
10486 }
10487
10488 fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
10489 Expr::Binary(op, Box::new(a), Box::new(b))
10490 }
10491
10492 fn un(op: UnaryOp, a: Expr) -> Expr {
10493 Expr::Unary(op, Box::new(a))
10494 }
10495
10496 /// `NlProblemParts` for an `n`-variable, unbounded model.
10497 fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
10498 let m = constraints.len();
10499 NlProblemParts {
10500 minimize: true,
10501 objective,
10502 obj_constant: 0.0,
10503 constraints,
10504 x_l: vec![-1e19; n],
10505 x_u: vec![1e19; n],
10506 x0: vec![0.0; n],
10507 g_l: vec![-1e19; m],
10508 g_u: vec![1e19; m],
10509 var_names: Vec::new(),
10510 con_names: Vec::new(),
10511 }
10512 }
10513
10514 /// A model built from expressions evaluates exactly like a parsed one:
10515 /// objective, gradient, constraints, and Jacobian all come from the
10516 /// same tape, with no `.nl` text in the loop.
10517 #[test]
10518 fn from_expressions_builds_evaluable_problem() {
10519 // min (1-x0)^2 + 100*(x1 - x0^2)^2 s.t. x0^2 + x1^2 <= 2
10520 let rosen = bin(
10521 BinOp::Add,
10522 bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
10523 bin(
10524 BinOp::Mul,
10525 c(100.0),
10526 bin(
10527 BinOp::Pow,
10528 bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
10529 c(2.0),
10530 ),
10531 ),
10532 );
10533 let circle = bin(
10534 BinOp::Add,
10535 bin(BinOp::Pow, v(0), c(2.0)),
10536 bin(BinOp::Pow, v(1), c(2.0)),
10537 );
10538
10539 let mut p = parts(2, rosen, vec![circle]);
10540 p.g_l = vec![0.0];
10541 p.g_u = vec![2.0];
10542 p.x0 = vec![-1.2, 1.0];
10543 p.var_names = names(&["x", "y"]);
10544 p.con_names = names(&["circle"]);
10545
10546 let prob = NlProblem::from_expressions(p).expect("build");
10547 assert_eq!((prob.n, prob.m), (2, 1));
10548 assert_eq!(prob.var_names, names(&["x", "y"]));
10549
10550 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10551 t.get_nlp_info().unwrap();
10552
10553 // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
10554 let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
10555 assert!((f - 24.2).abs() < 1e-10, "f = {f}");
10556
10557 // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
10558 // = (4.4 + 480*(-0.44)... ) — computed below rather than
10559 // transcribed, so the check is the formula, not an editor.
10560 let (x0, x1) = (-1.2, 1.0);
10561 let want = [
10562 -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
10563 200.0 * (x1 - x0 * x0),
10564 ];
10565 let mut g = [0.0_f64; 2];
10566 assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
10567 for j in 0..2 {
10568 assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
10569 }
10570
10571 // g(x) = x0^2 + x1^2 = 2.44
10572 let mut gv = [0.0_f64; 1];
10573 assert!(t.eval_g(&[x0, x1], true, &mut gv));
10574 assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
10575 }
10576
10577 /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
10578 /// this path. None of the three survives a `.nl` round trip in a
10579 /// typical frontend — `atan2` has no two-argument funcall path,
10580 /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
10581 /// at all — which is the reason the in-memory door exists.
10582 #[test]
10583 fn from_expressions_carries_ops_nl_cannot_express() {
10584 let obj = Expr::Sum(vec![
10585 bin(BinOp::Atan2, v(0), v(1)),
10586 Expr::MinList(vec![v(0), v(1)]),
10587 Expr::MaxList(vec![v(0), v(1)]),
10588 un(UnaryOp::Erf, v(0)),
10589 ]);
10590 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10591 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10592 t.get_nlp_info().unwrap();
10593
10594 let x: [Number; 2] = [0.8, 1.5];
10595 // atan2 + min + max + erf; min+max == x0+x1 for any pair.
10596 let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
10597 let f = t.eval_f(&x, true).unwrap();
10598 assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
10599 }
10600
10601 /// A `Var` index past `n` would be an out-of-bounds read in the
10602 /// tape's forward sweep. It has to be caught at construction, while
10603 /// it is still a diagnosable user error.
10604 #[test]
10605 fn from_expressions_rejects_out_of_range_var() {
10606 let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
10607 .expect_err("Var(5) with n = 2 must be rejected");
10608 assert!(err.contains("Var(5)"), "{err}");
10609
10610 let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
10611 .expect_err("constraint Var(2) with n = 2 must be rejected");
10612 assert!(err.contains("constraint 0"), "{err}");
10613
10614 // Length mismatches are errors too, not panics.
10615 let mut p = parts(2, c(0.0), Vec::new());
10616 p.x0 = vec![0.0; 3];
10617 let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
10618 assert!(err.contains("x0"), "{err}");
10619 }
10620
10621 /// An out-of-range `Var` must be caught wherever it hides, not just at
10622 /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
10623 /// branch, and a funcall argument all reach the same forward sweep.
10624 #[test]
10625 fn from_expressions_finds_out_of_range_vars_in_every_position() {
10626 let inner_cse = Arc::new(v(7));
10627 let cases: Vec<(&str, Expr)> = vec![
10628 ("bare", v(7)),
10629 ("cse", Expr::Cse(Arc::new(v(7)))),
10630 ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
10631 (
10632 "cond branch",
10633 Expr::Cond {
10634 cond: Box::new(c(1.0)),
10635 then_: Box::new(v(7)),
10636 else_: Box::new(c(0.0)),
10637 },
10638 ),
10639 ("min list", Expr::MinList(vec![c(0.0), v(7)])),
10640 (
10641 "sum",
10642 Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
10643 ),
10644 ];
10645 for (label, e) in cases {
10646 let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
10647 .err()
10648 .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
10649 assert!(err.contains("Var(7)"), "{label}: {err}");
10650 }
10651 }
10652
10653 /// A balanced share-DAG: each level is one `Cse` whose body
10654 /// references the level below twice. Depth `d` is `d` distinct nodes
10655 /// but `2^d` paths, so any walk that re-enters a shared body per
10656 /// occurrence is Θ(2^d).
10657 fn share_dag(depth: usize) -> Expr {
10658 let mut e = v(0);
10659 for _ in 0..depth {
10660 let shared = Arc::new(e);
10661 e = bin(
10662 BinOp::Add,
10663 Expr::Cse(Arc::clone(&shared)),
10664 Expr::Cse(shared),
10665 );
10666 }
10667 e
10668 }
10669
10670 /// The walks over a shared DAG must be memoized, not exponential.
10671 ///
10672 /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
10673 /// does not "fail" so much as never finish, which is exactly the
10674 /// signal. `from_expressions` is the door that makes such a DAG
10675 /// trivially constructible, but the blowup was reachable through
10676 /// `collect_vars` (which presolve calls on every solve, via
10677 /// `get_variables_linearity`) and `collect_funcall_ids` (which
10678 /// `NlTnlp::try_new` runs over every row).
10679 #[test]
10680 fn shared_dag_walks_are_memoized_not_exponential() {
10681 const DEPTH: usize = 30;
10682 let e = share_dag(DEPTH);
10683
10684 let mut vars = BTreeSet::new();
10685 collect_vars(&e, &mut vars);
10686 assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
10687
10688 let mut ids = BTreeSet::new();
10689 super::super::nl_external::collect_funcall_ids(&e, &mut ids);
10690 assert!(ids.is_empty());
10691
10692 // The whole build path, end to end: validation, tape construction,
10693 // and the linearity metadata presolve consumes.
10694 let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
10695 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10696 t.get_nlp_info().unwrap();
10697 let mut lin = vec![Linearity::Linear; 1];
10698 assert!(t.get_variables_linearity(&mut lin));
10699 }
10700
10701 /// `from_expressions` cannot carry AMPL imported functions — there is
10702 /// nowhere to put the `F`-segment declarations that bind a funcall id
10703 /// to a library — so a `Funcall` must be refused up front. Accepting it
10704 /// produces "AMPLFUNC is not set", which the user cannot act on:
10705 /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
10706 #[test]
10707 fn from_expressions_rejects_imported_function_calls() {
10708 let call = Expr::Funcall {
10709 id: 0,
10710 args: vec![FuncallArg::Real(v(0))],
10711 };
10712 let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
10713 .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
10714 assert!(err.contains("imported function"), "{err}");
10715 assert!(
10716 err.contains("read_nl") || err.contains("parse_nl_text"),
10717 "the error must point at the paths that do support externals: {err}"
10718 );
10719
10720 // Also when buried in a constraint, behind a Cse.
10721 let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
10722 let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
10723 .expect_err("a buried Funcall must be rejected too");
10724 assert!(err.contains("constraint 0"), "{err}");
10725 }
10726
10727 /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
10728 /// same tapes, same weights, one seed instead of a color sweep. The
10729 /// objective and both constraints are chosen with cross terms so the
10730 /// off-diagonal blocks actually carry signal.
10731 #[test]
10732 fn hessian_vector_product_matches_dense_hessian() {
10733 let obj = Expr::Sum(vec![
10734 bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
10735 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10736 un(UnaryOp::Erf, v(2)),
10737 ]);
10738 let cons = vec![
10739 bin(
10740 BinOp::Add,
10741 bin(BinOp::Pow, v(0), c(2.0)),
10742 un(UnaryOp::Sin, v(2)),
10743 ),
10744 bin(BinOp::Mul, v(1), v(2)),
10745 ];
10746 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10747 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10748 let info = t.get_nlp_info().unwrap();
10749
10750 let x = [0.3, -0.7, 1.1];
10751 let lam = [0.5, -1.25];
10752 let obj_factor = 2.0;
10753
10754 // Dense Hessian from the sparse lower triangle.
10755 let nnz = info.nnz_h_lag as usize;
10756 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10757 assert!(t.eval_h(
10758 None,
10759 false,
10760 1.0,
10761 None,
10762 false,
10763 SparsityRequest::Structure {
10764 irow: &mut irow,
10765 jcol: &mut jcol
10766 }
10767 ));
10768 let mut hvals = vec![0.0_f64; nnz];
10769 assert!(t.eval_h(
10770 Some(&x),
10771 true,
10772 obj_factor,
10773 Some(&lam),
10774 true,
10775 SparsityRequest::Values { values: &mut hvals }
10776 ));
10777 let mut dense = [[0.0_f64; 3]; 3];
10778 for k in 0..nnz {
10779 let (i, j) = (irow[k] as usize, jcol[k] as usize);
10780 dense[i][j] += hvals[k];
10781 if i != j {
10782 dense[j][i] += hvals[k];
10783 }
10784 }
10785
10786 // Each unit seed recovers a column; a mixed seed catches an HVP
10787 // that only happens to be right on the basis vectors.
10788 let seeds: [[Number; 3]; 4] = [
10789 [1.0, 0.0, 0.0],
10790 [0.0, 1.0, 0.0],
10791 [0.0, 0.0, 1.0],
10792 [0.4, -1.3, 2.0],
10793 ];
10794 let mut out = vec![0.0; 3];
10795 for s in &seeds {
10796 t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
10797 .expect("hvp");
10798 for i in 0..3 {
10799 let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
10800 assert!(
10801 (out[i] - want).abs() < 1e-9,
10802 "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
10803 out[i]
10804 );
10805 }
10806 }
10807 }
10808
10809 /// `lam = None` is the objective block alone, and `out` is
10810 /// overwritten (not accumulated) so a reused buffer is safe.
10811 #[test]
10812 fn hessian_vector_product_defaults_and_validation() {
10813 // f = x0^2 + 3 x0 x1 -> ∇²f = [[2, 3], [3, 0]]
10814 let obj = bin(
10815 BinOp::Add,
10816 bin(BinOp::Pow, v(0), c(2.0)),
10817 bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
10818 );
10819 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10820 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10821 t.get_nlp_info().unwrap();
10822
10823 let mut out = vec![7.0, -7.0]; // dirty buffer
10824 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
10825 .expect("hvp");
10826 assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
10827 assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
10828
10829 // obj_factor scales linearly.
10830 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
10831 .expect("hvp");
10832 assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
10833
10834 // Length mismatches are errors, not panics or silent truncation.
10835 let mut short = vec![0.0; 1];
10836 assert!(
10837 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
10838 .is_err()
10839 );
10840 assert!(
10841 t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
10842 .is_err()
10843 );
10844 assert!(
10845 t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
10846 .is_err()
10847 );
10848 }
10849
10850 /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
10851 /// Hessian — the sparse shape an IPM actually meets. The block HVP has
10852 /// to reproduce it column for column, including the structural zeros:
10853 /// a bug that leaked coupling between non-adjacent variables would
10854 /// show up here and nowhere in a small dense test.
10855 #[test]
10856 fn hessian_vector_products_on_a_sparse_hessian() {
10857 const N: usize = 8;
10858 let mut terms = Vec::new();
10859 for i in 0..N - 1 {
10860 terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
10861 }
10862 for i in 0..N {
10863 terms.push(un(UnaryOp::Exp, v(i)));
10864 }
10865 let prob =
10866 NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
10867 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10868 let info = t.get_nlp_info().unwrap();
10869
10870 // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
10871 assert_eq!(
10872 info.nnz_h_lag as usize,
10873 2 * N - 1,
10874 "chain objective should give a tridiagonal Hessian, not a dense one"
10875 );
10876
10877 let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
10878
10879 // Densify the sparse triangle for the reference.
10880 let nnz = info.nnz_h_lag as usize;
10881 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10882 assert!(t.eval_h(
10883 None,
10884 false,
10885 1.0,
10886 None,
10887 false,
10888 SparsityRequest::Structure {
10889 irow: &mut irow,
10890 jcol: &mut jcol
10891 }
10892 ));
10893 let mut hvals = vec![0.0; nnz];
10894 assert!(t.eval_h(
10895 Some(&x),
10896 true,
10897 1.0,
10898 None,
10899 true,
10900 SparsityRequest::Values { values: &mut hvals }
10901 ));
10902 let mut dense = vec![vec![0.0; N]; N];
10903 for k in 0..nnz {
10904 let (i, j) = (irow[k] as usize, jcol[k] as usize);
10905 dense[i][j] += hvals[k];
10906 if i != j {
10907 dense[j][i] += hvals[k];
10908 }
10909 }
10910
10911 // One block of N unit seeds recovers the whole matrix — the
10912 // "densify via HVPs" path, in one call.
10913 let mut seeds = vec![0.0; N * N];
10914 for cc in 0..N {
10915 seeds[cc * N + cc] = 1.0;
10916 }
10917 let mut out = vec![0.0; N * N];
10918 t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
10919 .expect("block hvp");
10920 for cc in 0..N {
10921 for i in 0..N {
10922 assert!(
10923 (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
10924 "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
10925 out[cc * N + i],
10926 dense[i][cc]
10927 );
10928 }
10929 }
10930 }
10931
10932 /// The block form must agree with `k` separate single-vector calls
10933 /// (it shares one forward sweep across directions, so a bug there
10934 /// would show as a per-direction discrepancy), and must skip an
10935 /// all-zero direction without disturbing its neighbours.
10936 #[test]
10937 fn hessian_vector_products_match_repeated_single_calls() {
10938 let obj = Expr::Sum(vec![
10939 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10940 bin(BinOp::Pow, v(2), c(4.0)),
10941 bin(BinOp::Mul, v(0), v(2)),
10942 ]);
10943 let cons = vec![bin(BinOp::Mul, v(1), v(2))];
10944 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10945 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10946 t.get_nlp_info().unwrap();
10947
10948 let x = [0.4, -0.6, 1.3];
10949 let lam = [0.75];
10950 let cols: [[Number; 3]; 4] = [
10951 [1.0, 2.0, -3.0],
10952 [0.0, 0.0, 0.0], // the skipped direction
10953 [0.5, 0.0, 0.0],
10954 [-1.0, 1.0, 1.0],
10955 ];
10956
10957 let mut block = vec![0.0; 3 * cols.len()];
10958 let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
10959 t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
10960 .expect("block hvp");
10961
10962 for (c, col) in cols.iter().enumerate() {
10963 let mut single = vec![0.0; 3];
10964 t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
10965 .expect("single hvp");
10966 for i in 0..3 {
10967 assert!(
10968 (block[c * 3 + i] - single[i]).abs() < 1e-12,
10969 "direction {c} row {i}: block={:.12e} single={:.12e}",
10970 block[c * 3 + i],
10971 single[i]
10972 );
10973 }
10974 }
10975 // The zero direction really is zero, not stale scratch.
10976 assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
10977 }
10978
10979 /// `k = 0` is a legal empty block, and the length checks scale with
10980 /// `k` rather than assuming a single direction.
10981 #[test]
10982 fn hessian_vector_products_validate_block_shape() {
10983 let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
10984 .expect("build");
10985 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10986 t.get_nlp_info().unwrap();
10987
10988 let mut empty: Vec<Number> = Vec::new();
10989 assert!(
10990 t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
10991 .is_ok()
10992 );
10993
10994 // v sized for one direction while k says two.
10995 let mut out = vec![0.0; 4];
10996 assert!(
10997 t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
10998 .is_err()
10999 );
11000 // out sized for one direction while k says two.
11001 let mut short = vec![0.0; 2];
11002 assert!(
11003 t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
11004 .is_err()
11005 );
11006 }
11007
11008 /// A `maximize` model's objective is negated by the evaluator, and
11009 /// the HVP has to agree with `eval_h` about that — otherwise a
11010 /// Hessian-free step would climb where the sparse path descends.
11011 #[test]
11012 fn hessian_vector_product_respects_maximize_sign() {
11013 let obj = bin(BinOp::Pow, v(0), c(2.0));
11014 let mut p = parts(1, obj, Vec::new());
11015 p.minimize = false;
11016 let prob = NlProblem::from_expressions(p).expect("build");
11017 let mut t = NlTnlp::try_new(prob).expect("tnlp");
11018 t.get_nlp_info().unwrap();
11019
11020 // max x0^2 is minimized as -x0^2, so ∇² = -2.
11021 let mut out = vec![0.0; 1];
11022 t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
11023 .expect("hvp");
11024 assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
11025 }
11026
11027 // ---- gh#1042 R3: `|e|^p · |e|^q` merged at parse time ----------------
11028
11029 /// An unconstrained `.nl` model over `n` variables whose objective is
11030 /// the given expression-token body (one token per line).
11031 fn abs_pow_nl(n: usize, body: &[&str]) -> String {
11032 let mut s = format!(
11033 "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"
11034 );
11035 for t in body {
11036 s.push_str(t);
11037 s.push('\n');
11038 }
11039 s.push_str("b\n");
11040 for _ in 0..n {
11041 s.push_str("3\n");
11042 }
11043 s
11044 }
11045
11046 /// `(3·|x|^2)·|x|^0.85` as Vanderbei's `dallass` writes its head-loss
11047 /// terms. Read as written, the tape's product rule forms `0·∞` at
11048 /// `x = 0` and the Hessian is NaN there (measured; the gradient reads
11049 /// `0`), which fails the
11050 /// KKT factorization and forces a restoration entry (11 of `dallass`'s
11051 /// 12 on 0.13.0). Merged into `3·|x|^2.85` every value is unchanged and
11052 /// the derivatives at the kink are the finite limits, `0` and `0`.
11053 #[test]
11054 fn abs_pow_product_is_merged_and_its_derivatives_are_finite_at_the_kink() {
11055 let txt = abs_pow_nl(
11056 1,
11057 &[
11058 "o2", "o2", "n3", "o5", "o15", "v0", "n2", "o5", "o15", "v0", "n0.85",
11059 ],
11060 );
11061 let p = parse_nl_text(&txt).expect("parse");
11062 // The tree is `Mul(3, Pow(Abs(x0), 2.85))`.
11063 match &*p.obj_expr() {
11064 Expr::Binary(BinOp::Mul, k, f) => {
11065 assert!(matches!(**k, Expr::Const(c) if c == 3.0), "{k:?}");
11066 assert!(
11067 matches!(abs_pow_parts(f), Some((Expr::Var(0), q)) if q == 2.85),
11068 "{f:?}"
11069 );
11070 }
11071 other => panic!("not merged: {other:?}"),
11072 }
11073
11074 let mut t = NlTnlp::new(p);
11075 let info = t.get_nlp_info().unwrap();
11076 assert_eq!(info.nnz_h_lag, 1);
11077 let f = |t: &mut NlTnlp, x: f64| t.eval_f(&[x], true).unwrap();
11078 let g = |t: &mut NlTnlp, x: f64| {
11079 let mut g = [0.0];
11080 assert!(t.eval_grad_f(&[x], true, &mut g));
11081 g[0]
11082 };
11083 let h = |t: &mut NlTnlp, x: f64| {
11084 let mut v = [0.0];
11085 assert!(t.eval_h(
11086 Some(&[x]),
11087 true,
11088 1.0,
11089 None,
11090 true,
11091 SparsityRequest::Values { values: &mut v }
11092 ));
11093 v[0]
11094 };
11095 let (pw, k) = (2.85_f64, 3.0_f64);
11096 for &x in &[-2.0, -0.7, -1e-3, 0.0, 1e-3, 0.7, 2.0, 1e3] {
11097 let a = f64::abs(x);
11098 // The value is the product's: `k·|x|^2·|x|^0.85`.
11099 let want_f = k * a.powi(2) * a.powf(0.85);
11100 let want_g = k * pw * a.powf(pw - 1.0) * x.signum() * f64::from(u8::from(x != 0.0));
11101 let want_h = k * pw * (pw - 1.0) * a.powf(pw - 2.0);
11102 let (gf, gg, gh) = (f(&mut t, x), g(&mut t, x), h(&mut t, x));
11103 assert!(
11104 gf.is_finite() && gg.is_finite() && gh.is_finite(),
11105 "x={x}: {gf} {gg} {gh}"
11106 );
11107 let tol = |w: f64| 1e-13 * w.abs().max(1e-300);
11108 assert!(
11109 (gf - want_f).abs() <= tol(want_f),
11110 "f({x}) = {gf}, want {want_f}"
11111 );
11112 assert!(
11113 (gg - want_g).abs() <= tol(want_g),
11114 "g({x}) = {gg}, want {want_g}"
11115 );
11116 assert!(
11117 (gh - want_h).abs() <= tol(want_h),
11118 "h({x}) = {gh}, want {want_h}"
11119 );
11120 }
11121 // At the kink: exactly zero, not NaN.
11122 assert_eq!(
11123 (f(&mut t, 0.0), g(&mut t, 0.0), h(&mut t, 0.0)),
11124 (0.0, 0.0, 0.0)
11125 );
11126 // The Hessian matches a central difference of the gradient on both
11127 // sides of the kink.
11128 for &x in &[-1e-3, 1e-3] {
11129 let e = 1e-8;
11130 let fd = (g(&mut t, x + e) - g(&mut t, x - e)) / (2.0 * e);
11131 let hx = h(&mut t, x);
11132 assert!(
11133 (fd - hx).abs() <= 1e-6 * hx.abs(),
11134 "x={x}: fd {fd} vs h {hx}"
11135 );
11136 }
11137 }
11138
11139 /// Every near miss keeps the tree it always had: `Mul` of the two
11140 /// operands exactly as read. Different operands, a non-positive power, a
11141 /// bare (signed) base and a variable exponent are each a case where the
11142 /// merge would change a value, not only a derivative, or is not known to
11143 /// be the same function.
11144 #[test]
11145 fn abs_pow_near_misses_build_the_unmerged_tree() {
11146 let cases: [(&[&str], &[&str]); 5] = [
11147 // different operands
11148 (&["o5", "o15", "v0", "n2"], &["o5", "o15", "v1", "n0.85"]),
11149 // a negative power: `|0|^-1·|0|^2` is NaN, `|0|^1` is not
11150 (&["o5", "o15", "v0", "n-1"], &["o5", "o15", "v0", "n2"]),
11151 // a zero power
11152 (&["o5", "o15", "v0", "n0"], &["o5", "o15", "v0", "n2"]),
11153 // no abs: `x^0.5·x^0.5` is NaN for x < 0, `x^1` is not
11154 (&["o5", "v0", "n0.5"], &["o5", "v0", "n0.5"]),
11155 // a variable exponent
11156 (&["o5", "o15", "v0", "v1"], &["o5", "o15", "v0", "n2"]),
11157 ];
11158 for (a, b) in cases {
11159 let mut body = vec!["o2"];
11160 body.extend_from_slice(a);
11161 body.extend_from_slice(b);
11162 let p = parse_nl_text(&abs_pow_nl(2, &body)).expect("parse");
11163 let frag = |toks: &[&str]| {
11164 let txt: String = toks.iter().map(|t| format!("{t}\n")).collect();
11165 parse_body_fragment(&txt, 2, &[]).expect("fragment")
11166 };
11167 let want = Expr::Binary(BinOp::Mul, Box::new(frag(a)), Box::new(frag(b)));
11168 assert_eq!(
11169 format!("{:?}", p.obj_expr()),
11170 format!("{want:?}"),
11171 "near miss {body:?} was rewritten"
11172 );
11173 }
11174 }
11175
11176 /// The merge reaches into one `Mul` level on either side and keeps the
11177 /// remaining factors, so `|x|^2·(|x|^0.85·y)` and `(|x|^0.5·y)·|x|^1.5`
11178 /// become `|x|^2.85·y` and `|x|^2·y` — the latter lowered to a mul chain
11179 /// by the tape — with values unchanged.
11180 #[test]
11181 fn abs_pow_merge_keeps_the_other_factors() {
11182 for (body, pw) in [
11183 (
11184 &[
11185 "o2", "o5", "o15", "v0", "n2", "o2", "o5", "o15", "v0", "n0.85", "v1",
11186 ][..],
11187 2.85,
11188 ),
11189 (
11190 &[
11191 "o2", "o2", "o5", "o15", "v0", "n0.5", "v1", "o5", "o15", "v0", "n1.5",
11192 ][..],
11193 2.0,
11194 ),
11195 ] {
11196 let p = parse_nl_text(&abs_pow_nl(2, body)).expect("parse");
11197 match &*p.obj_expr() {
11198 Expr::Binary(BinOp::Mul, l, r) => {
11199 assert!(
11200 matches!(abs_pow_parts(l), Some((Expr::Var(0), q)) if q == pw),
11201 "{l:?}"
11202 );
11203 assert!(matches!(**r, Expr::Var(1)), "{r:?}");
11204 }
11205 other => panic!("not merged: {other:?}"),
11206 }
11207 let mut t = NlTnlp::new(p);
11208 for &(x, y) in &[(-1.3, 0.4), (0.0, 2.0), (2.5, -1.0)] {
11209 let want = f64::abs(x).powf(pw) * y;
11210 let got = t.eval_f(&[x, y], true).unwrap();
11211 assert!(
11212 (got - want).abs() <= 1e-13 * want.abs(),
11213 "{body:?} at ({x},{y}): {got} vs {want}"
11214 );
11215 }
11216 }
11217 }
11218}