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}
760
761/// The pieces of a model built in memory, as handed to
762/// [`NlProblem::from_expressions`].
763///
764/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
765/// split to fill in, because the AD tape treats a linear term exactly like
766/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
767/// optimization, not an evaluator requirement). `n` is taken from the length
768/// of `x_l`; `m` from the length of `constraints`.
769///
770/// One cost to that simplification, in *metadata* rather than values: with
771/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
772/// when its expression is literally `Const(0.0)`, so a genuinely linear row
773/// built here reports `NonLinear`. Presolve consumes that tag, and the
774/// direction is the safe one — it loses tightening it could have done, and
775/// never asserts linearity that does not hold — but a frontend that cares
776/// about presolve strength on linear rows should know the tag is
777/// pessimistic on this path.
778#[derive(Debug, Clone)]
779pub struct NlProblemParts {
780 /// `true` to minimize `objective`, `false` to maximize it. Matches
781 /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
782 /// so callers always see the minimization form.
783 pub minimize: bool,
784 /// Objective expression.
785 pub objective: Expr,
786 /// Constant offset added to the objective.
787 pub obj_constant: Number,
788 /// One expression per constraint row; row `i` is bounded by
789 /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
790 pub constraints: Vec<Expr>,
791 /// Variable bounds and starting point, each length `n`. Use `±1e19`
792 /// for "unbounded", the same sentinel the `.nl` reader emits.
793 pub x_l: Vec<Number>,
794 pub x_u: Vec<Number>,
795 pub x0: Vec<Number>,
796 /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
797 /// equality row.
798 pub g_l: Vec<Number>,
799 pub g_u: Vec<Number>,
800 /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
801 /// consumer falls back to indices (see [`NlProblem::var_names`]).
802 pub var_names: Vec<String>,
803 pub con_names: Vec<String>,
804}
805
806impl NlProblem {
807 /// Assemble a problem from expression trees, with no `.nl` file
808 /// anywhere in the loop (issue #469).
809 ///
810 /// A modeling frontend that already has its own expression DAG should
811 /// come in here rather than serialize to `.nl` and re-parse: the round
812 /// trip is not only slower, it is *lossy*, because `.nl` writers
813 /// routinely refuse operators this tape supports natively (`atan2`,
814 /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
815 ///
816 /// The result is an ordinary [`NlProblem`], so it feeds
817 /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
818 /// does: objective, gradient, constraints, Jacobian + structure,
819 /// Lagrangian Hessian + structure, and
820 /// [`NlTnlp::hessian_vector_product`].
821 ///
822 /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
823 /// — the latter would otherwise be an out-of-bounds read in the tape's
824 /// forward sweep, so it must be caught while it is still a diagnosable
825 /// user error.
826 pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
827 let NlProblemParts {
828 minimize,
829 objective,
830 obj_constant,
831 constraints,
832 x_l,
833 x_u,
834 x0,
835 g_l,
836 g_u,
837 var_names,
838 con_names,
839 } = parts;
840
841 let n = x_l.len();
842 let m = constraints.len();
843 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
844 if got == want {
845 Ok(())
846 } else {
847 Err(format!(
848 "from_expressions: {name} has length {got}, expected {want}"
849 ))
850 }
851 };
852 check("x_u", x_u.len(), n)?;
853 check("x0", x0.len(), n)?;
854 check("g_l", g_l.len(), m)?;
855 check("g_u", g_u.len(), m)?;
856 if !var_names.is_empty() {
857 check("var_names", var_names.len(), n)?;
858 }
859 if !con_names.is_empty() {
860 check("con_names", con_names.len(), m)?;
861 }
862
863 // Numeric validation, the same screen the `.nl` reader applies
864 // (gh #847). `lower_bound_present` / `upper_bound_present` are
865 // `is_finite() && ...`, so a caller that builds a model here and hands
866 // in a non-finite bound gets it silently dropped -- read as "no bound
867 // declared" -- exactly as a file containing `1e400` was. `-inf` on a
868 // lower side and `+inf` on an upper one are the sentinel said a
869 // different way and are normalized; everything else, `NaN` included,
870 // is refused.
871 let mut x_l = x_l;
872 let mut x_u = x_u;
873 let mut g_l = g_l;
874 let mut g_u = g_u;
875 for (i, v) in x_l.iter_mut().enumerate() {
876 *v = finite_bound_or_err(&format!("x_l[{i}]"), *v, true)
877 .map_err(|e| format!("from_expressions: {e}"))?;
878 }
879 for (i, v) in x_u.iter_mut().enumerate() {
880 *v = finite_bound_or_err(&format!("x_u[{i}]"), *v, false)
881 .map_err(|e| format!("from_expressions: {e}"))?;
882 }
883 for (i, v) in g_l.iter_mut().enumerate() {
884 *v = finite_bound_or_err(&format!("g_l[{i}]"), *v, true)
885 .map_err(|e| format!("from_expressions: {e}"))?;
886 }
887 for (i, v) in g_u.iter_mut().enumerate() {
888 *v = finite_bound_or_err(&format!("g_u[{i}]"), *v, false)
889 .map_err(|e| format!("from_expressions: {e}"))?;
890 }
891 for (i, v) in x0.iter().enumerate() {
892 finite_or_err(&format!("x0[{i}]"), *v).map_err(|e| format!("from_expressions: {e}"))?;
893 }
894 finite_or_err("obj_constant", obj_constant)
895 .map_err(|e| format!("from_expressions: {e}"))?;
896
897 // Structural validation. Memoized on `Cse` pointer identity so a
898 // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
899 let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
900 validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
901 for (i, c) in constraints.iter().enumerate() {
902 validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
903 }
904
905 Ok(NlProblem {
906 n,
907 m,
908 num_obj: 1,
909 minimize,
910 obj_nonlinear: NlBody::Tree(objective),
911 obj_linear: Vec::new(),
912 obj_constant,
913 con_nonlinear: constraints.into_iter().map(NlBody::Tree).collect(),
914 con_linear: vec![Vec::new(); m],
915 x_l,
916 x_u,
917 g_l,
918 g_u,
919 x0,
920 lambda0: vec![0.0; m],
921 suffixes: NlSuffixes::default(),
922 imported_funcs: Vec::new(),
923 ampl_options: Vec::new(),
924 // No header was read, so there is no census to report. Consumers
925 // fall back to walking the trees, which is what they would have
926 // to do here anyway.
927 nl_counts: None,
928 n_discrete: None,
929 var_names,
930 con_names,
931 // Built from trees, so every body has one and there is nothing
932 // to rebuild from.
933 src: None,
934 cse_bodies: Vec::new(),
935 })
936 }
937
938 /// The objective's nonlinear body as an [`Expr`].
939 ///
940 /// Borrowed when the tree is resident, rebuilt when the parser
941 /// recognized the body and skipped building it. The rebuild re-parses
942 /// the body's own bytes with the same parser that produced the rest of
943 /// the model, so the result is the tree a non-recognizing parse would
944 /// have produced — structurally identical, coefficient bit patterns
945 /// included, and sharing the same `Cse` allocations.
946 ///
947 /// It is not free: it allocates the nodes the recognizer avoided. Reach
948 /// for [`NlBody::quad`] first if the coefficients are what you want.
949 pub fn obj_expr(&self) -> std::borrow::Cow<'_, Expr> {
950 self.body_expr(&self.obj_nonlinear, "objective")
951 }
952
953 /// Row `k`'s nonlinear body as an [`Expr`]. See [`Self::obj_expr`].
954 ///
955 /// # Panics
956 ///
957 /// Panics if `k >= m`, or if re-parsing a recognized body fails — the
958 /// latter cannot happen for a problem this crate produced (the bytes
959 /// parsed once already, by this code) and means the `NlProblem` was
960 /// assembled by hand with a `src` that does not match its bodies.
961 pub fn con_expr(&self, k: usize) -> std::borrow::Cow<'_, Expr> {
962 self.body_expr(&self.con_nonlinear[k], "constraint")
963 }
964
965 fn body_expr<'a>(&'a self, body: &'a NlBody, what: &str) -> std::borrow::Cow<'a, Expr> {
966 match body {
967 NlBody::Tree(e) => std::borrow::Cow::Borrowed(e),
968 NlBody::Quad(q) => {
969 let src = self
970 .src
971 .as_deref()
972 .unwrap_or_else(|| panic!("{what} body was recognized but no source is kept"));
973 std::borrow::Cow::Owned(
974 parse_body_fragment(&src[q.src.clone()], self.n, &self.cse_bodies)
975 .unwrap_or_else(|e| panic!("re-parsing a recognized {what} body: {e}")),
976 )
977 }
978 }
979 }
980}
981
982/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
983/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
984///
985/// Both are things the tape cannot recover from later. An out-of-range
986/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
987/// worse-looking than it is fatal: `from_expressions` has nowhere to put
988/// the `F`-segment declarations an AMPL imported function needs
989/// (`NlProblemParts` has no field for them, and the built problem's
990/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
991/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
992/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
993/// failure to "funcall id N has no F<N> declaration". Rejecting it here
994/// says the true thing: this door does not carry external functions; go
995/// through `read_nl` / `parse_nl_text` for a model that needs them.
996///
997/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
998/// passing one set through a whole problem keeps the walk linear in
999/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
1000/// repeat visit is sound because the caller aborts on the first violation:
1001/// reaching a body a second time proves the first visit found none.
1002fn validate_expr(
1003 e: &Expr,
1004 n: usize,
1005 seen: &mut std::collections::HashSet<*const Expr>,
1006) -> Result<(), String> {
1007 match e {
1008 Expr::Const(_) => Ok(()),
1009 Expr::Var(i) => {
1010 if *i < n {
1011 Ok(())
1012 } else {
1013 Err(format!("references Var({i}) but n = {n}"))
1014 }
1015 }
1016 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1017 validate_expr(a, n, seen)?;
1018 validate_expr(b, n, seen)
1019 }
1020 Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
1021 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1022 for a in args {
1023 validate_expr(a, n, seen)?;
1024 }
1025 Ok(())
1026 }
1027 Expr::Cond { cond, then_, else_ } => {
1028 validate_expr(cond, n, seen)?;
1029 validate_expr(then_, n, seen)?;
1030 validate_expr(else_, n, seen)
1031 }
1032 Expr::Cse(body) => {
1033 if seen.insert(Arc::as_ptr(body)) {
1034 validate_expr(body, n, seen)
1035 } else {
1036 Ok(())
1037 }
1038 }
1039 Expr::Funcall { id, .. } => Err(format!(
1040 "references AMPL imported function id {id}, which this path cannot \
1041 resolve: a problem built from expressions has no F-segment \
1042 declarations to bind it to. Load such a model with read_nl or \
1043 parse_nl_text instead."
1044 )),
1045 }
1046}
1047
1048/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
1049/// into dense vectors at problem load time so callers can index by
1050/// variable / constraint number directly. Empty maps when the `.nl`
1051/// file declared no suffixes.
1052#[derive(Debug, Clone, Default)]
1053pub struct NlSuffixes {
1054 /// Variable-level integer suffixes (kind = 0). Each vector has
1055 /// length `n_full` (problem variables).
1056 pub var_int: BTreeMap<String, Vec<Index>>,
1057 /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
1058 pub con_int: BTreeMap<String, Vec<Index>>,
1059 /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
1060 pub obj_int: BTreeMap<String, Vec<Index>>,
1061 /// Problem-level integer suffixes (kind = 3). Single value per name.
1062 pub problem_int: BTreeMap<String, Index>,
1063 /// Variable-level real suffixes (kind = 4). Length `n_full`.
1064 pub var_real: BTreeMap<String, Vec<Number>>,
1065 /// Constraint-level real suffixes (kind = 5). Length `m_full`.
1066 pub con_real: BTreeMap<String, Vec<Number>>,
1067 /// Objective-level real suffixes (kind = 6). Length `num_obj`.
1068 pub obj_real: BTreeMap<String, Vec<Number>>,
1069 /// Problem-level real suffixes (kind = 7). Single value per name.
1070 pub problem_real: BTreeMap<String, Number>,
1071}
1072
1073/// Deepest expression nesting the reader will build (gh#986). On a thread
1074/// whose stack is declared with [`set_parse_stack_budget`] the limit is
1075/// lowered to what that stack holds at the worst-case frame size; the CLI
1076/// runs on a 1 GiB thread and `pounce-py` on a 1 GiB worker (each falling
1077/// back to a smaller reservation, and a correspondingly lower limit, when the
1078/// OS refuses). A left-deep `o0` sum is read flat and does not count.
1079pub const MAX_PARSE_DEPTH: u32 = 40_000;
1080
1081thread_local! {
1082 /// Stack bytes the current thread offers the recursive reader
1083 /// (gh#986), set by whoever spawned it with a known stack. `None` (the
1084 /// default) means "unknown": [`DEFAULT_ASSUMED_STACK`] is assumed.
1085 static PARSE_STACK_BUDGET: std::cell::Cell<Option<usize>> =
1086 const { std::cell::Cell::new(None) };
1087}
1088
1089/// Tell the reader how much stack this thread has, so its depth guard fires
1090/// before the stack does at *any* build profile (gh#986). Call it first thing
1091/// on a thread created with an explicit stack size (the CLI's main thread, the
1092/// Python worker); `None` clears it.
1093pub fn set_parse_stack_budget(bytes: Option<usize>) {
1094 PARSE_STACK_BUDGET.with(|b| b.set(bytes));
1095}
1096
1097/// Stack the recursive reader (parse, tape build, drop glue) needs per
1098/// nesting level, as a worst case over build profiles. Measured on a
1099/// negation chain 39 900 deep: an unoptimised build (`opt-level = 0`, what
1100/// `cargo build` / `maturin develop` produce) fails at 512 MiB and passes at
1101/// 1 GiB, i.e. 13–26 KiB per level; optimised builds use well under 2 KiB.
1102const PARSE_LEVEL_BYTES: usize = if cfg!(debug_assertions) {
1103 32 * 1024
1104} else {
1105 4 * 1024
1106};
1107
1108/// Stack a thread is assumed to have when nobody declared one with
1109/// [`set_parse_stack_budget`] (gh#986 review). The first pass gave such a
1110/// thread the full [`MAX_PARSE_DEPTH`], sized for the CLI's 1 GiB thread, so a
1111/// library caller of [`read_nl_file`] on an ordinary thread, or the wasm
1112/// build, aborted on a stack overflow where the guard was meant to refuse.
1113/// The assumption is the smallest stack such a caller normally runs on: Rust's
1114/// default for a spawned thread (2 MiB, also what the test harness uses) on
1115/// native targets, and the 1 MiB `rustc` gives a wasm32 module.
1116const DEFAULT_ASSUMED_STACK: usize = if cfg!(target_arch = "wasm32") {
1117 1 << 20
1118} else {
1119 2 << 20
1120};
1121
1122/// The depth limit for the current thread: [`MAX_PARSE_DEPTH`], lowered to
1123/// what the thread's declared stack holds -- or, undeclared, what
1124/// [`DEFAULT_ASSUMED_STACK`] holds.
1125fn parse_depth_limit() -> u32 {
1126 let bytes = PARSE_STACK_BUDGET
1127 .with(|b| b.get())
1128 .unwrap_or(DEFAULT_ASSUMED_STACK);
1129 ((bytes / PARSE_LEVEL_BYTES).min(MAX_PARSE_DEPTH as usize)) as u32
1130}
1131
1132/// A run of binary `o0` longer than this is read as one n-ary `Sum`
1133/// (gh#986); shorter runs keep their binary tree.
1134const SUM_CHAIN_FLATTEN_MIN: usize = 64;
1135
1136/// Parse an `.nl` file from disk.
1137///
1138/// After parsing the `.nl` body, this also looks for AMPL's optional
1139/// sibling name files — `stub.col` (variable names) and `stub.row`
1140/// (constraint names), emitted only when the modeler sets
1141/// `option auxfiles rc;`. When present and well-formed they populate
1142/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
1143/// malformed the names stay empty and every downstream consumer falls
1144/// back to indices. Names are a diagnostic nicety, never load-blocking
1145/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1146pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
1147 // AMPL invokes a solver with an extensionless *stub* — e.g.
1148 // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
1149 // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
1150 // path as given is missing but appending `.nl` names an existing file,
1151 // resolve to that. This only ever *adds* a fallback: an existing path
1152 // is read verbatim, so nothing changes for callers that already pass a
1153 // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
1154 let resolved = if path.exists() {
1155 path.to_path_buf()
1156 } else {
1157 let with_nl = append_extension(path, "nl");
1158 if with_nl.exists() {
1159 with_nl
1160 } else {
1161 path.to_path_buf()
1162 }
1163 };
1164 let txt = std::fs::read_to_string(&resolved)
1165 .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
1166 // By value: a recognized body keeps a byte range into this text rather
1167 // than a tree, so it is moved into the problem instead of copied.
1168 let mut prob = parse_nl_string(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())?;
1169 prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
1170 prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
1171 Ok(prob)
1172}
1173
1174/// Append `.ext` to `path`'s full file name (AMPL stub convention:
1175/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
1176/// which would *replace* an existing extension. A stub that itself
1177/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
1178/// way AMPL names it.
1179fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
1180 let mut name = path.as_os_str().to_os_string();
1181 name.push(".");
1182 name.push(ext);
1183 std::path::PathBuf::from(name)
1184}
1185
1186/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
1187/// order. Returns the first `expected` names, or an empty vector when the
1188/// file is missing, unreadable, or has fewer than `expected` lines.
1189///
1190/// Returning empty (rather than erroring) on any mismatch is deliberate:
1191/// names are an optional diagnostic aid, so a missing or truncated file
1192/// must never block a solve. The `.take(expected)` also drops AMPL's
1193/// convention of appending the objective name after the constraint names
1194/// in `.row`, keeping the result aligned 1:1 with `g`.
1195fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
1196 let Ok(txt) = std::fs::read_to_string(path) else {
1197 return Vec::new();
1198 };
1199 let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
1200 if names.len() == expected {
1201 names
1202 } else {
1203 Vec::new()
1204 }
1205}
1206
1207/// The value of a constraint's nonlinear part when that part is a
1208/// constant, else `None`. Drives the constant-row-body fold in
1209/// [`parse_nl_text`].
1210///
1211/// "Constant" is decided by *evaluation*, not by syntax: a literal
1212/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
1213/// and is folded too.
1214///
1215/// Declines in two cases, both of which would make the fold unsound:
1216/// * The expression calls an AMPL imported function. Its value depends on
1217/// a shared library resolved much later (`nl_external::ExternalResolver`),
1218/// so it is not a parse-time constant even with constant arguments — and
1219/// [`eval_expr`] panics on `Funcall` rather than guess.
1220/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
1221/// a NaN or infinity into a bound would corrupt a row that is merely
1222/// infeasible; leaving the expression in place keeps it a solver-time
1223/// fact.
1224fn row_constant_value(e: &Expr) -> Option<Number> {
1225 // The identity zero the parser preallocates for every untouched row is
1226 // by far the most common input; settle it without walking anything.
1227 if let Expr::Const(c) = e {
1228 return c.is_finite().then_some(*c);
1229 }
1230 let mut vars: BTreeSet<usize> = BTreeSet::new();
1231 collect_vars(e, &mut vars);
1232 if !vars.is_empty() {
1233 return None;
1234 }
1235 let mut funcs: BTreeSet<usize> = BTreeSet::new();
1236 crate::nl_external::collect_funcall_ids(e, &mut funcs);
1237 if !funcs.is_empty() {
1238 return None;
1239 }
1240 // Variable-free, so no `Expr::Var` can index into the (empty) point.
1241 let v = eval_expr(e, &[]);
1242 v.is_finite().then_some(v)
1243}
1244
1245/// Parse `.nl` text content. Public so tests can use string literals.
1246///
1247/// Degree-2 bodies are recognized as the token stream is read, so their
1248/// `Expr` trees are never built (gh #588, Q5); `POUNCE_DBG_NO_QUAD=1`
1249/// turns that off and restores the pre-Q5 parse exactly, which is what the
1250/// A/B reference and gh #540's guard test need. See
1251/// [`parse_nl_text_with_quadratic`] for the same switch as a parameter.
1252pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
1253 parse_nl_text_with_quadratic(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
1254}
1255
1256/// [`parse_nl_text`] with parse-time quadratic recognition explicitly on
1257/// or off.
1258///
1259/// The env var the plain entry point reads is process-global, which is
1260/// exactly wrong for a differential test that has to parse the *same* text
1261/// both ways in one process and compare the results — so the knob is a
1262/// parameter here, mirroring [`NlTnlp::try_new_with_quadratic`].
1263pub fn parse_nl_text_with_quadratic(txt: &str, use_quadratic: bool) -> Result<NlProblem, String> {
1264 parse_nl_string(txt.to_string(), use_quadratic)
1265}
1266
1267/// [`parse_nl_text_with_quadratic`], taking the text by value.
1268///
1269/// A recognized body keeps a byte range into the source rather than a tree,
1270/// so the source outlives the parse. Taking ownership means the file
1271/// [`read_nl_file`] already read is *moved* into the problem instead of
1272/// copied: the text is resident during parsing either way, so this costs
1273/// nothing at the peak the phase is about.
1274pub fn parse_nl_string(txt: String, use_quadratic: bool) -> Result<NlProblem, String> {
1275 let src = Arc::new(txt);
1276 let mut p = Parser::new(&src, use_quadratic);
1277 p.parse_header()?;
1278 let n = p.n;
1279 let m = p.m;
1280 let num_obj = p.num_obj;
1281
1282 let mut con_nonlinear: Vec<NlBody> = (0..m).map(|_| NlBody::Tree(Expr::Const(0.0))).collect();
1283 let mut obj_nonlinear = NlBody::Tree(Expr::Const(0.0));
1284 let mut minimize = true;
1285 let mut obj_linear: Vec<(usize, Number)> = Vec::new();
1286 let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1287 let mut x_l = vec![-1e19; n];
1288 let mut x_u = vec![1e19; n];
1289 let mut g_l = vec![-1e19; m];
1290 let mut g_u = vec![1e19; m];
1291 let mut x0 = vec![0.0; n];
1292 let mut lambda0 = vec![0.0; m];
1293 let mut suffixes = NlSuffixes::default();
1294 let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
1295 // Segment presence, for the truncation check after the loop (gh#785).
1296 let mut saw_r = false;
1297 let mut saw_b = false;
1298
1299 while let Some(line) = p.peek_segment_line() {
1300 let tag = line
1301 .trim_start()
1302 .chars()
1303 .next()
1304 .ok_or("unexpected blank segment header")?;
1305 match tag {
1306 'C' => {
1307 let (_hdr, rest) = p.eat_segment_header()?;
1308 let _ = rest;
1309 let idx = parse_segment_index(_hdr, 'C')?;
1310 if idx >= m {
1311 return Err(format!("C{idx} out of range; m={m}"));
1312 }
1313 con_nonlinear[idx] = p.parse_body()?;
1314 }
1315 'O' => {
1316 let (hdr, _rest) = p.eat_segment_header()?;
1317 let parts: Vec<&str> = hdr.split_whitespace().collect();
1318 if parts.len() < 2 {
1319 return Err(format!("malformed O-segment header: {hdr}"));
1320 }
1321 let idx = parse_segment_index(parts[0], 'O')?;
1322 let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
1323 if idx == 0 {
1324 minimize = kind == 0;
1325 obj_nonlinear = p.parse_body()?;
1326 } else {
1327 // Extra objectives are read but ignored.
1328 let _ = p.parse_expr()?;
1329 }
1330 }
1331 'r' => {
1332 p.eat_segment_header()?;
1333 saw_r = true;
1334 for i in 0..m {
1335 let line = p.next_data_line()?;
1336 let (lo, hi) = parse_bound_line(line)?;
1337 g_l[i] = lo;
1338 g_u[i] = hi;
1339 }
1340 }
1341 'b' => {
1342 p.eat_segment_header()?;
1343 saw_b = true;
1344 for i in 0..n {
1345 let line = p.next_data_line()?;
1346 let (lo, hi) = parse_bound_line(line)?;
1347 x_l[i] = lo;
1348 x_u[i] = hi;
1349 }
1350 }
1351 'k' => {
1352 // Column counts in the Jacobian; we don't need their
1353 // values for evaluation (the J segments give explicit
1354 // lists), but we must consume exactly as many data lines
1355 // as follow or the segment stream desyncs. The `.nl`
1356 // format writes that line count in the header itself
1357 // (`k<count>`), and the standard value is `n-1`. Read the
1358 // declared count rather than assuming it: a file with a
1359 // nonstandard count would otherwise leave us reading the
1360 // wrong number of lines, swallowing a later segment header
1361 // (or stopping short) and failing with a confusing,
1362 // far-removed error. Validate against the expected `n-1`
1363 // so a mismatch surfaces here, clearly, at its source.
1364 let (hdr, _) = p.eat_segment_header()?;
1365 let declared = parse_segment_index(hdr, 'k')?;
1366 let expected = if n == 0 { 0 } else { n - 1 };
1367 if declared != expected {
1368 return Err(format!(
1369 "k-segment declares {declared} column-count lines but \
1370 the standard count for n={n} variables is {expected}"
1371 ));
1372 }
1373 for _ in 0..declared {
1374 p.next_data_line()?;
1375 }
1376 }
1377 'J' => {
1378 let (hdr, _) = p.eat_segment_header()?;
1379 let parts: Vec<&str> = hdr.split_whitespace().collect();
1380 if parts.len() < 2 {
1381 return Err(format!("malformed J-segment header: {hdr}"));
1382 }
1383 let row = parse_segment_index(parts[0], 'J')?;
1384 let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
1385 if row >= m {
1386 return Err(format!("J{row} out of range"));
1387 }
1388 for _ in 0..nz {
1389 let line = p.next_data_line()?;
1390 let (var, coef) = parse_var_coef(line)?;
1391 // Validate the column index here: an out-of-range `var`
1392 // would otherwise be stored and panic as a slice OOB
1393 // (`x[var]`) during constraint evaluation. Mirror the
1394 // clean parse error used for the row index above.
1395 if var >= n {
1396 return Err(format!(
1397 "J{row} entry variable index {var} out of range (n={n})"
1398 ));
1399 }
1400 con_linear[row].push((var, coef));
1401 }
1402 }
1403 'G' => {
1404 let (hdr, _) = p.eat_segment_header()?;
1405 let parts: Vec<&str> = hdr.split_whitespace().collect();
1406 if parts.len() < 2 {
1407 return Err(format!("malformed G-segment header: {hdr}"));
1408 }
1409 let idx = parse_segment_index(parts[0], 'G')?;
1410 let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
1411 let mut acc = Vec::with_capacity(nz);
1412 for _ in 0..nz {
1413 let line = p.next_data_line()?;
1414 let (var, coef) = parse_var_coef(line)?;
1415 // Same as J: reject an out-of-range gradient column index
1416 // up front rather than letting it panic on `x[var]` later.
1417 if var >= n {
1418 return Err(format!(
1419 "G{idx} entry variable index {var} out of range (n={n})"
1420 ));
1421 }
1422 acc.push((var, coef));
1423 }
1424 if idx == 0 {
1425 obj_linear = acc;
1426 }
1427 }
1428 'x' => {
1429 let (hdr, _) = p.eat_segment_header()?;
1430 let parts: Vec<&str> = hdr.split_whitespace().collect();
1431 let nx: usize = parts
1432 .first()
1433 .and_then(|s| s.trim_start_matches('x').parse().ok())
1434 .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
1435 for _ in 0..nx {
1436 let line = p.next_data_line()?;
1437 let (idx, val) = parse_var_coef(line)?;
1438 // Reject out-of-range indices as a parse error, matching
1439 // J/G strictness, rather than silently dropping the entry
1440 // (which hides a corrupt initial-primal segment).
1441 if idx >= n {
1442 return Err(format!(
1443 "x-segment variable index {idx} out of range (n={n})"
1444 ));
1445 }
1446 x0[idx] = val;
1447 }
1448 }
1449 'd' => {
1450 let (hdr, _) = p.eat_segment_header()?;
1451 let parts: Vec<&str> = hdr.split_whitespace().collect();
1452 let nd: usize = parts
1453 .first()
1454 .and_then(|s| s.trim_start_matches('d').parse().ok())
1455 .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
1456 for _ in 0..nd {
1457 let line = p.next_data_line()?;
1458 let (idx, val) = parse_var_coef(line)?;
1459 // Reject out-of-range indices as a parse error, matching
1460 // J/G strictness, rather than silently dropping the entry
1461 // (which hides a corrupt initial-dual segment).
1462 if idx >= m {
1463 return Err(format!(
1464 "d-segment constraint index {idx} out of range (m={m})"
1465 ));
1466 }
1467 // A `d` entry is in AMPL's dual sign (`d obj / d b`, the
1468 // sign a `.sol` dual block carries), and pounce's
1469 // multiplier is that of `L = f + lambda' g`, i.e. `-dual`.
1470 // `sol_writer` negates on the way out; negate on the way
1471 // in so a `.nl` start and a `.sol` result share one sign
1472 // (gh#1033; matches Ipopt's AMPL driver).
1473 lambda0[idx] = -val;
1474 }
1475 }
1476 'V' => p.parse_v_segment()?,
1477 'S' => {
1478 parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
1479 }
1480 'F' => {
1481 // AMPL imported (external) function declaration:
1482 // `F<k> <type> <nargs> <name>`.
1483 let (hdr, _rest) = p.eat_segment_header()?;
1484 let parts: Vec<&str> = hdr.split_whitespace().collect();
1485 if parts.is_empty() {
1486 return Err(format!("malformed F-segment header: '{hdr}'"));
1487 }
1488 let id = parse_segment_index(parts[0], 'F')?;
1489 let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1490 let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1491 let name = parts.get(3).copied().unwrap_or("").to_string();
1492 imported_funcs.push(ImportedFunc {
1493 id,
1494 kind,
1495 nargs,
1496 name,
1497 });
1498 }
1499 other => return Err(format!("unknown .nl segment tag '{other}'")),
1500 }
1501 }
1502
1503 // A `.nl` file that ends early is not a smaller model — it is a corrupt
1504 // one, and every segment the truncation ate is a piece of the problem
1505 // that silently reverts to a default: a dropped `r` leaves every row at
1506 // ±1e19 (i.e. unconstrained), a dropped `b` leaves every variable free,
1507 // a dropped `J` leaves every row's linear part empty. The segment loop
1508 // cannot tell those defaults from a legitimately absent segment, so the
1509 // truncated model solves, and reports `SolveSucceeded` with a
1510 // confidently wrong objective — which is strictly worse than the parse
1511 // error every other malformed input already gets (gh#785). The header
1512 // declares enough to tell the two apart; check it here, once, against
1513 // what the segments actually delivered.
1514 //
1515 // `r` and `b` are unconditional in the format: AMPL writes both whenever
1516 // the model has rows / columns at all, free rows and free variables
1517 // included — those get the `3` ("no bounds") code, not an omitted line.
1518 if m > 0 && !saw_r {
1519 return Err(format!(
1520 "missing `r` (constraint-bounds) segment for a model declaring {m} \
1521 constraint(s): the .nl file is truncated or corrupt"
1522 ));
1523 }
1524 if n > 0 && !saw_b {
1525 return Err(format!(
1526 "missing `b` (variable-bounds) segment for a model declaring {n} \
1527 variable(s): the .nl file is truncated or corrupt"
1528 ));
1529 }
1530 // The `J` segments must deliver exactly the Jacobian nonzero count the
1531 // header declares — `nzc` is the sum of their lengths by construction,
1532 // nonlinear-only columns included (they are written with a zero
1533 // coefficient, not omitted). This is the check that catches a truncation
1534 // landing *after* `r` and `b`, where the bounds are all present and only
1535 // the coefficients are gone.
1536 if let Some(declared) = p.declared_jac_nnz {
1537 let parsed: usize = con_linear.iter().map(Vec::len).sum();
1538 if parsed != declared {
1539 return Err(format!(
1540 "header declares {declared} Jacobian nonzero(s) but the J \
1541 segments supply {parsed}: the .nl file is truncated or corrupt"
1542 ));
1543 }
1544 }
1545
1546 // Normalize constant row bodies into the row bounds, so that
1547 // "the nonlinear part is the identity zero" and "this row's body is
1548 // Σaⱼxⱼ" mean the same thing for every downstream consumer.
1549 //
1550 // A `C<i>` segment holding a variable-free expression (`n3.0`, or
1551 // anything that evaluates to a constant such as `o0 n1 n2`) is an
1552 // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
1553 // which every consumer reads as "nonlinear": the linearity predicate
1554 // (`get_constraints_linearity`), which is what makes presolve's
1555 // linear-equality reduction decline the row; the CLI problem
1556 // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
1557 // polynomial walk absorbs a bare literal but not a constant it has to
1558 // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
1559 // NLP and never reached the convex path); and the FBBT translator.
1560 // Folding here fixes all of them at once, with no per-consumer audit.
1561 //
1562 // The shift is exact and invisible from outside: the body drops by `c`
1563 // and each bound drops by `c` with it, so the feasible set, the active
1564 // set, and the duals are unchanged (`gh #492`). This is the same
1565 // normalization `qp_extract::analyze_quadratic_full` already performs
1566 // ad hoc via `const_shift`, promoted to the parse boundary.
1567 for i in 0..m {
1568 // A recognized body is degree 2, so it is not a constant row and
1569 // this fold has nothing to do with it.
1570 let Some(tree) = con_nonlinear[i].tree() else {
1571 continue;
1572 };
1573 let Some(c) = row_constant_value(tree) else {
1574 continue;
1575 };
1576 // Presence is directional (gh #401): shifting an *absent* bound
1577 // would turn the ±1e19 sentinel into a real bound for `c < 0`
1578 // (lower) or `c > 0` (upper), inventing a constraint. Leave the
1579 // sentinels alone.
1580 if lower_bound_present(g_l[i]) {
1581 g_l[i] -= c;
1582 }
1583 if upper_bound_present(g_u[i]) {
1584 g_u[i] -= c;
1585 }
1586 con_nonlinear[i] = NlBody::Tree(Expr::Const(0.0));
1587 }
1588
1589 // The source is kept only when something in it has no tree of its own.
1590 // A model with nothing recognized is byte-for-byte the pre-Q5 problem,
1591 // extra field included.
1592 let any_recognized =
1593 obj_nonlinear.quad().is_some() || con_nonlinear.iter().any(|b| b.quad().is_some());
1594 let kept_src = any_recognized.then(|| Arc::clone(&src));
1595
1596 // A `d` entry was stored as `-dual` above, which is the engine's
1597 // multiplier only for a minimize model. A maximize model is solved as
1598 // `min -f`, and the CLI writes its `.sol` dual as `-sigma * lambda`
1599 // (sigma = -1, gh#959), so the inverse is `lambda = -sigma * dual`.
1600 // Applied here because the `O` segment fixing the sense may follow `d`.
1601 if !minimize {
1602 for v in lambda0.iter_mut() {
1603 *v = -*v;
1604 }
1605 }
1606 Ok(NlProblem {
1607 n,
1608 m,
1609 num_obj,
1610 minimize,
1611 obj_nonlinear,
1612 obj_linear,
1613 obj_constant: 0.0,
1614 con_nonlinear,
1615 con_linear,
1616 x_l,
1617 x_u,
1618 g_l,
1619 g_u,
1620 x0,
1621 lambda0,
1622 suffixes,
1623 ampl_options: p.ampl_options.clone(),
1624 nl_counts: p.nl_counts,
1625 n_discrete: p.n_discrete,
1626 imported_funcs,
1627 // `.nl` text carries no names; `read_nl_file` fills these from the
1628 // sibling `.col`/`.row` files when present.
1629 var_names: Vec::new(),
1630 con_names: Vec::new(),
1631 src: kept_src,
1632 cse_bodies: p.cses.clone(),
1633 })
1634}
1635
1636/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
1637/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
1638///
1639/// ```text
1640/// S<kind> <nentries> <suffix_name>
1641/// <idx> <value> ... nentries lines
1642/// ```
1643///
1644/// `<kind>` is a 3-bit encoding:
1645/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
1646/// 2 = objectives, 3 = problem-level.
1647/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
1648///
1649/// Sparse entries scatter into a freshly-allocated dense vector (zero
1650/// default), sized for the target dimension. Problem-level suffixes
1651/// (kind = 3 / 7) carry a single value.
1652fn parse_suffix_segment(
1653 p: &mut Parser,
1654 n: usize,
1655 m: usize,
1656 num_obj: usize,
1657 out: &mut NlSuffixes,
1658) -> Result<(), String> {
1659 let (hdr, _) = p.eat_segment_header()?;
1660 let parts: Vec<&str> = hdr.split_whitespace().collect();
1661 if parts.len() < 3 {
1662 return Err(format!(
1663 "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
1664 ));
1665 }
1666 let kind_str = parts[0].trim_start_matches('S');
1667 let kind: u32 = kind_str
1668 .parse()
1669 .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
1670 let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
1671 let name = parts[2].to_string();
1672
1673 let is_real = (kind & 0x4) != 0;
1674 let target = kind & 0x3;
1675 let target_dim = match target {
1676 0 => n,
1677 1 => m,
1678 2 => num_obj,
1679 3 => 0, // problem-level — entries are single-valued (idx=0)
1680 _ => unreachable!("kind & 0x3 is in 0..=3"),
1681 };
1682
1683 // Pre-allocate dense buffers (default zero). Problem-level kinds
1684 // (3 / 7) hold a single scalar — we still read the (idx, value)
1685 // pairs but only the value field is meaningful.
1686 let mut int_buf: Vec<Index> = if !is_real && target != 3 {
1687 vec![0; target_dim]
1688 } else {
1689 Vec::new()
1690 };
1691 let mut real_buf: Vec<Number> = if is_real && target != 3 {
1692 vec![0.0; target_dim]
1693 } else {
1694 Vec::new()
1695 };
1696 let mut problem_int: Index = 0;
1697 let mut problem_real: Number = 0.0;
1698
1699 for _ in 0..nentries {
1700 let line = p.next_data_line()?;
1701 let parts: Vec<&str> = line.split_whitespace().collect();
1702 if parts.len() < 2 {
1703 return Err(format!(
1704 "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
1705 ));
1706 }
1707 let idx: usize = parts[0]
1708 .parse()
1709 .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
1710 if target != 3 && idx >= target_dim {
1711 return Err(format!(
1712 "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
1713 ));
1714 }
1715 if is_real {
1716 let v: Number = parts[1]
1717 .parse()
1718 .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
1719 if target == 3 {
1720 problem_real = v;
1721 } else {
1722 real_buf[idx] = v;
1723 }
1724 } else {
1725 let v: Index = parts[1]
1726 .parse()
1727 .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
1728 if target == 3 {
1729 problem_int = v;
1730 } else {
1731 int_buf[idx] = v;
1732 }
1733 }
1734 }
1735
1736 match (target, is_real) {
1737 (0, false) => {
1738 out.var_int.insert(name, int_buf);
1739 }
1740 (1, false) => {
1741 out.con_int.insert(name, int_buf);
1742 }
1743 (2, false) => {
1744 out.obj_int.insert(name, int_buf);
1745 }
1746 (3, false) => {
1747 out.problem_int.insert(name, problem_int);
1748 }
1749 (0, true) => {
1750 out.var_real.insert(name, real_buf);
1751 }
1752 (1, true) => {
1753 out.con_real.insert(name, real_buf);
1754 }
1755 (2, true) => {
1756 out.obj_real.insert(name, real_buf);
1757 }
1758 (3, true) => {
1759 out.problem_real.insert(name, problem_real);
1760 }
1761 _ => unreachable!(),
1762 }
1763 Ok(())
1764}
1765
1766fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1767 let trimmed = s.trim_start_matches(tag);
1768 trimmed
1769 .parse()
1770 .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1771}
1772
1773// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1774// `G` / `x` / `d` data line, so between them they see every Jacobian and
1775// gradient nonzero in the file. Both walk the whitespace iterator
1776// directly instead of collecting a `Vec<&str>` first — that collect was
1777// a heap allocation per line on top of the one the reader used to make
1778// handing the line over.
1779/// Refuse a non-finite number read out of a `.nl` file (gh #847).
1780///
1781/// `str::parse::<f64>()` accepts `inf`, `-inf` and `nan`, and it also *returns*
1782/// `inf` for any literal that overflows the type — `1e400` is a plausible thing
1783/// for a model generator to write. Nothing downstream treats such a value as an
1784/// error, and in one place it is actively misread: `lower_bound_present` /
1785/// `upper_bound_present` are `is_finite() && ...`, so a non-finite bound is
1786/// indistinguishable from a bound that was never declared, and is silently
1787/// dropped. On a model that a lower bound of `1e300` makes infeasible, the same
1788/// bound written `1e400` returned `EXIT: Optimal Solution Found.` with exit code
1789/// 0. A `nan` is worse: it propagates into the answer, and the solve reports
1790/// `Objective: nan` under `Solve_Succeeded`.
1791///
1792/// Ipopt refuses this input ("Invalid number"), POUNCE's own NLP arm refuses it,
1793/// and `pounce.solve_qp` refuses a non-finite bound with a bespoke `ValueError`.
1794/// There is no reading on which `Optimal` is the intended answer, so the reader
1795/// refuses it too.
1796fn finite_or_err(what: &str, v: Number) -> Result<Number, String> {
1797 if v.is_finite() {
1798 Ok(v)
1799 } else {
1800 Err(format!(
1801 "invalid number: {what} is {v}, which is not finite"
1802 ))
1803 }
1804}
1805
1806/// The same screen for a *bound* slot, where one non-finite value has an
1807/// unambiguous meaning and is normalized instead of refused.
1808///
1809/// `.nl` states "no bound" with a bound **kind** (1 = upper only, 2 = lower
1810/// only, 3 = free), so a non-finite number in a bound slot is a corrupt value
1811/// rather than a notation — with one exception per side. `-inf` in a *lower*
1812/// slot and `+inf` in an *upper* slot say precisely what the `±1e19` sentinel
1813/// says, and a writer that emits them means it, so they map to the sentinel.
1814///
1815/// Everything else is refused, and the asymmetry is the whole point: `+inf` as
1816/// a *lower* bound is the gh #847 case. It is not "unbounded below" — it is an
1817/// empty box, and reading it as "absent" is what turned an infeasible model
1818/// into an `Optimal` one. `NaN` is refused on either side, having no meaning at
1819/// all.
1820fn finite_bound_or_err(what: &str, v: Number, lower: bool) -> Result<Number, String> {
1821 if v.is_finite() {
1822 return Ok(v);
1823 }
1824 if lower && v == Number::NEG_INFINITY {
1825 return Ok(-1e19);
1826 }
1827 if !lower && v == Number::INFINITY {
1828 return Ok(1e19);
1829 }
1830 Err(format!(
1831 "invalid number: {what} is {v}, which is not finite (a `.nl` file \
1832 states an absent bound with a bound kind of 1, 2 or 3, not with a \
1833 non-finite value)"
1834 ))
1835}
1836
1837fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1838 let mut parts = line.split_whitespace();
1839 let kind: i32 = parts
1840 .next()
1841 .ok_or("empty bound line")?
1842 .parse()
1843 .map_err(|e| format!("bound kind: {e}"))?;
1844 let lo;
1845 let hi;
1846 match kind {
1847 0 => {
1848 // 0 lo hi
1849 let (l, h) = (parts.next(), parts.next());
1850 let (Some(l), Some(h)) = (l, h) else {
1851 return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1852 };
1853 lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1854 hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1855 }
1856 1 => {
1857 // 1 hi
1858 let Some(h) = parts.next() else {
1859 return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1860 };
1861 lo = -1e19;
1862 hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1863 }
1864 2 => {
1865 // 2 lo
1866 let Some(l) = parts.next() else {
1867 return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1868 };
1869 lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1870 hi = 1e19;
1871 }
1872 3 => {
1873 // 3 (free)
1874 lo = -1e19;
1875 hi = 1e19;
1876 }
1877 4 => {
1878 // 4 eq
1879 let Some(v) = parts.next() else {
1880 return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1881 };
1882 // An equality has no "absent" side, so neither infinity is a
1883 // notation here and both are refused.
1884 let v: Number = finite_or_err("eq bound", v.parse().map_err(|e| format!("eq: {e}"))?)?;
1885 lo = v;
1886 hi = v;
1887 }
1888 5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1889 other => return Err(format!("unknown bound kind {other}")),
1890 }
1891 Ok((lo, hi))
1892}
1893
1894fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1895 let mut parts = line.split_whitespace();
1896 let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1897 return Err(format!("malformed var/coef line: '{line}'"));
1898 };
1899 let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1900 let c: Number = finite_or_err("coefficient", c.parse().map_err(|e| format!("coef: {e}"))?)?;
1901 Ok((v, c))
1902}
1903
1904/// Build an [`NlCounts`] from `.nl` header lines 3 (`nlc nlo`) and 5
1905/// (`nlvc nlvo nlvb`).
1906///
1907/// Returns `None` unless both lines carry the full complement of
1908/// non-negative integers, so a truncated or non-conforming header reads as
1909/// "unknown" rather than as zeros — "no nonlinear variables" is a claim, and
1910/// a header that failed to parse has not made it.
1911fn parse_nl_counts(line3: &str, line5: &str) -> Option<NlCounts> {
1912 let nums = |line: &str, want: usize| -> Option<Vec<usize>> {
1913 let v: Vec<usize> = line
1914 .split_whitespace()
1915 .take(want)
1916 .map(str::parse)
1917 .collect::<Result<_, _>>()
1918 .ok()?;
1919 (v.len() == want).then_some(v)
1920 };
1921 let cons_objs = nums(line3, 2)?;
1922 let vars = nums(line5, 3)?;
1923 Some(NlCounts {
1924 nl_cons: cons_objs[0],
1925 nl_objs: cons_objs[1],
1926 nl_vars_cons: vars[0],
1927 nl_vars_objs: vars[1],
1928 nl_vars_both: vars[2],
1929 })
1930}
1931
1932/// Header line 7: `nbv niv nlvbi nlvci nlvoi` (gh#987).
1933///
1934/// In AMPL's convention `nbv` and `niv` count only the discrete variables
1935/// that appear **linearly**; a discrete variable that appears nonlinearly is
1936/// counted in `nlvbi` / `nlvci` / `nlvoi` instead, *not* in `nbv + niv`. So a
1937/// MINLP whose integers all appear nonlinearly has `nbv = niv = 0` — reading
1938/// only those two fields reports it as continuous.
1939#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1940pub struct DiscreteCensus {
1941 /// `nbv`: binary variables appearing only linearly.
1942 pub binary: usize,
1943 /// `niv`: other integer variables appearing only linearly.
1944 pub integer: usize,
1945 /// `nlvbi`: integer variables nonlinear in both constraints and objectives.
1946 pub nl_both: usize,
1947 /// `nlvci`: integer variables nonlinear in constraints only.
1948 pub nl_cons: usize,
1949 /// `nlvoi`: integer variables nonlinear in objectives only.
1950 pub nl_objs: usize,
1951}
1952
1953impl DiscreteCensus {
1954 /// Total declared discrete variables, linear and nonlinear
1955 /// (`nbv + niv + nlvbi + nlvci + nlvoi`).
1956 pub fn total(&self) -> usize {
1957 self.binary + self.integer + self.nonlinear_discrete()
1958 }
1959
1960 /// Discrete variables that appear nonlinearly (`nlvbi + nlvci + nlvoi`).
1961 pub fn nonlinear_discrete(&self) -> usize {
1962 self.nl_both + self.nl_cons + self.nl_objs
1963 }
1964
1965 /// The 0-based columns of every discrete variable, from Gay's variable
1966 /// ordering ("Writing .nl Files", table 4):
1967 ///
1968 /// 1. nonlinear in both constraints and objectives — `nlvb` columns, the
1969 /// last `nlvbi` of them integer;
1970 /// 2. nonlinear in constraints only — `nlvc − nlvb` columns, the last
1971 /// `nlvci` integer;
1972 /// 3. nonlinear in objectives only — `nlvo − nlvc` columns (when
1973 /// positive), the last `nlvoi` integer;
1974 /// 4. linear variables, ending in `nbv` binary then `niv` integer, which
1975 /// are therefore the last `nbv + niv` columns of the model.
1976 ///
1977 /// `None` when the columns cannot be identified: a nonlinear integer
1978 /// count is non-zero but the header's line-5 census is missing, or the
1979 /// counts are inconsistent with the block sizes. Callers must report that
1980 /// as *not checked*, never as passed.
1981 pub fn integer_columns(&self, n: usize, counts: Option<NlCounts>) -> Option<Vec<usize>> {
1982 let lin = self.binary + self.integer;
1983 if lin > n {
1984 return None;
1985 }
1986 let mut cols = Vec::with_capacity(self.total());
1987 if self.nonlinear_discrete() > 0 {
1988 let c = counts?;
1989 let nlvb = c.nl_vars_both;
1990 let nlvc = c.nl_vars_cons;
1991 let nlvo = c.nl_vars_objs;
1992 if nlvc < nlvb {
1993 return None;
1994 }
1995 let b3 = nlvo.saturating_sub(nlvc);
1996 let blocks = [
1997 (0, nlvb, self.nl_both),
1998 (nlvb, nlvc - nlvb, self.nl_cons),
1999 (nlvc, b3, self.nl_objs),
2000 ];
2001 for (start, len, ints) in blocks {
2002 if ints > len || start + len > n - lin {
2003 return None;
2004 }
2005 cols.extend(start + len - ints..start + len);
2006 }
2007 }
2008 cols.extend(n - lin..n);
2009 Some(cols)
2010 }
2011}
2012
2013struct Parser<'a> {
2014 lines: Vec<&'a str>,
2015 /// Start address and length of the source text. A recognized body
2016 /// records the byte *range* it consumed so it can be re-parsed later
2017 /// (see [`QuadBody::src`]), and every line borrows from this buffer, so
2018 /// one subtraction per line recovers its offset.
2019 ///
2020 /// Deliberately not a per-line offset table: on a 119 MB generated
2021 /// `qcqp500-3c` the file is ~17 M lines, and a `Vec<usize>` beside the
2022 /// existing `Vec<&str>` would add 136 MB to the peak this phase exists
2023 /// to reduce.
2024 txt_base: usize,
2025 txt_len: usize,
2026 pos: usize,
2027 n: usize,
2028 m: usize,
2029 num_obj: usize,
2030 /// Number of AMPL imported (external) functions declared in the header.
2031 n_funcs: usize,
2032 /// Header lines 3 and 5, when both parsed. See [`NlCounts`].
2033 nl_counts: Option<NlCounts>,
2034 /// `(nbv, niv)` from header line 7.
2035 n_discrete: Option<DiscreteCensus>,
2036 /// `nzc` from header line 8: the number of Jacobian nonzeros the file
2037 /// *declares*. [`parse_nl_string`] cross-checks it against the number the
2038 /// `J` segments actually deliver, which is how a file truncated before
2039 /// them is told from a model that genuinely has none (gh#785). `None`
2040 /// when the header does not carry it in the documented shape.
2041 declared_jac_nnz: Option<usize>,
2042 ampl_options: Vec<i64>,
2043 /// Common subexpressions (`V` segments). Index in this vec is the
2044 /// CSE-local index, i.e. the global `.nl` index minus `n`.
2045 cses: Vec<Arc<Expr>>,
2046 /// Recognize degree-2 bodies from the token stream instead of building
2047 /// their trees (gh #588, Q5). Off restores the pre-Q5 parse exactly.
2048 quad_enabled: bool,
2049 /// Per-CSE answers the streaming recognizer needs about a `V` body it
2050 /// may be handed a reference to, all keyed by CSE-local index: the
2051 /// body's own degree-≤2 form, whether it is legal on a sum spine,
2052 /// whether it is legal *inside* a monomial, and its variable support.
2053 ///
2054 /// These are computed from the built `V` body with the same functions
2055 /// `NlTnlp` would apply to a whole row, so a reference costs a lookup
2056 /// and cannot drift from what the tree walk would have said. `V` bodies
2057 /// keep their trees regardless — they are shared, so the memory is
2058 /// amortized over every reference, and dropping them would mean
2059 /// rebuilding them for the rows that are *not* recognized.
2060 cse_quad: Vec<Option<Quad2>>,
2061 cse_sum_ok: Vec<bool>,
2062 cse_mono_ok: Vec<bool>,
2063 cse_vars: Vec<Vec<u32>>,
2064 cse_depth: Vec<u32>,
2065 /// gh#986 review: the depth of every `V` body *through* the defined
2066 /// variables it references (a `Cse` counting as its body), keyed by the
2067 /// body's `Arc` pointer. Always kept, whatever `quad_enabled` says: it is
2068 /// what bounds the recursion of every later pass that walks through a
2069 /// `Cse` (tape build, `collect_vars`, evaluation, drop glue), and it makes
2070 /// [`expr_tree_depth`] linear in the body rather than in the whole chain.
2071 cse_depth_memo: std::collections::HashMap<*const Expr, u32>,
2072 /// `V` body pointer -> its index in `cses` (and in the `cse_*` caches),
2073 /// so the parse-time passes can read an earlier body's verdict instead of
2074 /// re-walking it (gh#986 review).
2075 cse_index: std::collections::HashMap<*const Expr, usize>,
2076 /// Current recursion depth of `parse_expr` (gh#986).
2077 parse_depth: u32,
2078 /// Depth at which `parse_expr` refuses (gh#986); see [`parse_depth_limit`].
2079 max_depth: u32,
2080}
2081
2082/// One pending operator on the streaming recognizer's stack.
2083struct QFrame {
2084 op: QOp,
2085 /// Operands still to be read.
2086 remaining: usize,
2087 /// Read this frame's operands in monomial mode — no `+`/`-` may appear
2088 /// below it. See [`crate::nl_quadratic::is_expanded_quadratic`].
2089 mono: bool,
2090}
2091
2092/// The operators the streaming recognizer accepts. One variant per shape
2093/// that [`crate::nl_quadratic::recognize_expr`] handles; everything else
2094/// makes it bail.
2095enum QOp {
2096 Neg,
2097 Add,
2098 Sub,
2099 Mul,
2100 Div,
2101 /// `o5`/`o81`/`o83`: base and exponent both read.
2102 Pow,
2103 /// `o82`: square, exponent implicit.
2104 Square,
2105 Sum(usize),
2106}
2107
2108impl<'a> Parser<'a> {
2109 fn new(txt: &'a str, quad_enabled: bool) -> Self {
2110 let lines: Vec<&str> = txt.lines().collect();
2111 Self {
2112 lines,
2113 txt_base: txt.as_ptr() as usize,
2114 txt_len: txt.len(),
2115 pos: 0,
2116 n: 0,
2117 m: 0,
2118 num_obj: 0,
2119 n_funcs: 0,
2120 nl_counts: None,
2121 n_discrete: None,
2122 declared_jac_nnz: None,
2123 ampl_options: Vec::new(),
2124 cses: Vec::new(),
2125 quad_enabled,
2126 cse_quad: Vec::new(),
2127 cse_sum_ok: Vec::new(),
2128 cse_mono_ok: Vec::new(),
2129 cse_vars: Vec::new(),
2130 cse_depth: Vec::new(),
2131 cse_depth_memo: std::collections::HashMap::new(),
2132 cse_index: std::collections::HashMap::new(),
2133 parse_depth: 0,
2134 max_depth: parse_depth_limit(),
2135 }
2136 }
2137
2138 /// Byte offset in the source text of the start of line `line`, or the
2139 /// end of the text when the cursor has run off it.
2140 ///
2141 /// Pointer arithmetic against the same allocation, never a deref: every
2142 /// line in `lines` borrows from the source buffer, so the difference is
2143 /// its offset.
2144 fn byte_at(&self, line: usize) -> usize {
2145 match self.lines.get(line) {
2146 Some(l) => l.as_ptr() as usize - self.txt_base,
2147 None => self.txt_len,
2148 }
2149 }
2150
2151 fn next_line(&mut self) -> Option<&'a str> {
2152 while self.pos < self.lines.len() {
2153 let l = self.lines[self.pos];
2154 self.pos += 1;
2155 // Strip comment after '#' for header / data lines (but
2156 // leave the segment-tag tokens untouched — they are the
2157 // first token on the line).
2158 let trimmed = strip_comment(l).trim();
2159 if !trimmed.is_empty() {
2160 return Some(l);
2161 }
2162 }
2163 None
2164 }
2165
2166 /// Next non-blank line, comment stripped and trimmed.
2167 ///
2168 /// Borrows from the source text rather than allocating: the result
2169 /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
2170 /// outlives the `&mut self` this took. A large `.nl` is mostly data
2171 /// lines — 620k of them for a 20k-variable model — and returning an
2172 /// owned `String` put one heap allocation on every single one.
2173 fn next_data_line(&mut self) -> Result<&'a str, String> {
2174 while self.pos < self.lines.len() {
2175 let l = self.lines[self.pos];
2176 self.pos += 1;
2177 let trimmed = strip_comment(l).trim();
2178 if !trimmed.is_empty() {
2179 return Ok(trimmed);
2180 }
2181 }
2182 Err("unexpected end of file in data line".to_string())
2183 }
2184
2185 fn parse_header(&mut self) -> Result<(), String> {
2186 let line0 = self.next_line().ok_or("empty .nl file")?;
2187 let trimmed = strip_comment(line0).trim();
2188 let first = trimmed.chars().next().ok_or("empty header line")?;
2189 if first != 'g' {
2190 return Err(format!(
2191 "only ASCII (g-) .nl files supported; got header '{trimmed}'"
2192 ));
2193 }
2194 // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
2195 // `g` say how many AMPL option words follow on the same line.
2196 // A solver echoes them back in the `.sol` `Options` block, so
2197 // keep them verbatim. Malformed or truncated option lists are not
2198 // fatal — the writer falls back to a generic block.
2199 let mut words = trimmed.split_whitespace();
2200 let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
2201 let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
2202 if opts.len() >= n_opts {
2203 self.ampl_options = opts[..n_opts].to_vec();
2204 }
2205
2206 // Header line 2: n_vars n_cons n_objs ranges eqns
2207 let l2 = self.next_data_line()?;
2208 let nums: Vec<&str> = l2.split_whitespace().collect();
2209 if nums.len() < 3 {
2210 return Err(format!("malformed line 2: '{l2}'"));
2211 }
2212 self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
2213 self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
2214 self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
2215
2216 // Header line 3: `nlc nlo`. Line 4: the network-constraint census,
2217 // which pounce has no use for. Line 5: `nlvc nlvo nlvb`. Together
2218 // these are the model's nonlinearity census — see [`NlCounts`].
2219 //
2220 // A header that does not carry them in the documented shape leaves
2221 // `nl_counts` at `None` rather than at a guess: every consumer has a
2222 // walk-the-trees fallback, and a fabricated count is worse than an
2223 // absent one. The rest of the header stays tolerant in the same way
2224 // the `nfunc` read below is.
2225 let l3 = self.next_data_line()?;
2226 let _l4_network = self.next_data_line()?;
2227 let l5 = self.next_data_line()?;
2228 self.nl_counts = parse_nl_counts(l3, l5);
2229 // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
2230 let l6 = self.next_data_line()?;
2231 let nums5: Vec<&str> = l6.split_whitespace().collect();
2232 self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
2233 // Lines 6..10 are metadata we mostly don't need. Line 7 is the
2234 // discrete-variable census; line 8 is `nzc nzo` — the Jacobian and
2235 // objective-gradient nonzero counts, kept because `nzc` is what
2236 // catches a file truncated before its `J` segments (gh#785); lines
2237 // 9 and 10 are the maximum name lengths and the common-expression
2238 // census.
2239 let l7_discrete = self.next_data_line()?;
2240 {
2241 let v: Vec<usize> = l7_discrete
2242 .split_whitespace()
2243 .map_while(|w| w.parse::<usize>().ok())
2244 .collect();
2245 if v.len() >= 2 {
2246 self.n_discrete = Some(DiscreteCensus {
2247 binary: v[0],
2248 integer: v[1],
2249 // `nlvbi nlvci nlvoi`; absent in a short header.
2250 nl_both: v.get(2).copied().unwrap_or(0),
2251 nl_cons: v.get(3).copied().unwrap_or(0),
2252 nl_objs: v.get(4).copied().unwrap_or(0),
2253 });
2254 }
2255 }
2256 let l8 = self.next_data_line()?;
2257 // Tolerant like the `nfunc` read above: a header that does not
2258 // carry the count in the documented shape leaves it `None`, and
2259 // the cross-check that reads it is skipped rather than fabricated.
2260 self.declared_jac_nnz = l8.split_whitespace().next().and_then(|s| s.parse().ok());
2261 let _l9_name_lens = self.next_data_line()?;
2262 let _l10_common_exprs = self.next_data_line()?;
2263 Ok(())
2264 }
2265
2266 fn peek_segment_line(&mut self) -> Option<&'a str> {
2267 let saved = self.pos;
2268 let l = self.next_line()?;
2269 self.pos = saved;
2270 Some(l)
2271 }
2272
2273 /// Eat the next non-blank line as a segment header. Returns the
2274 /// whole header (after stripping comments) and the comment text.
2275 fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
2276 let raw = self
2277 .next_line()
2278 .ok_or_else(|| "expected segment header".to_string())?;
2279 let (hdr, comment) = split_comment(raw);
2280 Ok((hdr.trim(), comment.trim()))
2281 }
2282
2283 /// Parse one `C`/`O` body, recognizing it from the token stream when
2284 /// that is possible instead of building its tree (gh #588, Q5).
2285 ///
2286 /// The cursor is the whole mechanism. `Parser` is line-based, so a
2287 /// failed recognition costs one assignment to rewind — the same trick
2288 /// `parse_funcall_arg` already uses to route a Hollerith literal — and
2289 /// the fallback then produces exactly the tree this file has always
2290 /// produced. Nothing downstream can tell which arm ran except by
2291 /// measuring memory.
2292 ///
2293 /// Only **degree-2** forms are kept. A body that recognizes as constant
2294 /// or linear is re-parsed as a tree and stored as one: the constant-row
2295 /// fold (gh #492), the linearity contract and the classifier's LP fast
2296 /// path all key on the identity zero and on trees, they are cheap
2297 /// already, and the memory that motivates this phase is entirely in
2298 /// degree-2 rows. Refusing to touch them keeps the change confined to
2299 /// the rows it is for.
2300 fn parse_body(&mut self) -> Result<NlBody, String> {
2301 if !self.quad_enabled {
2302 let e = self.parse_expr()?;
2303 self.check_effective_depth(&e)?;
2304 return Ok(NlBody::Tree(e));
2305 }
2306 let saved = self.pos;
2307 if let Some((form, vars, depth)) = self.parse_expr_quadratic() {
2308 if !form.quadratic().is_empty() {
2309 let src = self.byte_at(saved)..self.byte_at(self.pos);
2310 return Ok(NlBody::Quad(Box::new(QuadBody {
2311 form,
2312 vars,
2313 src,
2314 depth,
2315 })));
2316 }
2317 }
2318 self.pos = saved;
2319 let e = self.parse_expr()?;
2320 self.check_effective_depth(&e)?;
2321 Ok(NlBody::Tree(e))
2322 }
2323
2324 /// gh#986 review: refuse a tree whose depth *through the defined
2325 /// variables it references* exceeds the guard. `parse_expr` counts only
2326 /// the syntactic nesting of the text it reads, and a `v<i>` reference to a
2327 /// `V` segment is one token there; a chain of `V` segments each referencing
2328 /// the previous is shallow to the parser and as deep as the chain to every
2329 /// pass that walks through a `Cse` (the tape builder, `collect_vars`, drop
2330 /// glue), so the stack the guard exists to protect was unguarded.
2331 fn check_effective_depth(&self, e: &Expr) -> Result<u32, String> {
2332 let d = expr_tree_depth(e, &self.cse_depth_memo);
2333 if d > self.max_depth {
2334 return Err(format!(
2335 "the model nests an expression {d} levels deep counting the defined \
2336 variables (V segments) it references, more than the {} levels the \
2337 recursive reader accepts; a chain of defined variables each \
2338 referencing the previous counts once per link",
2339 self.max_depth
2340 ));
2341 }
2342 Ok(d)
2343 }
2344
2345 /// Read one expression off the token stream as a degree-≤2 form,
2346 /// **without building it**, or give up.
2347 ///
2348 /// This is the same computation as
2349 /// `is_expanded_quadratic(e) && recognize_expr(e)` run on the tree these
2350 /// tokens parse to — the accuracy gate and the algebra, interleaved so
2351 /// neither needs the tree. That equivalence is the phase's correctness
2352 /// claim and is asserted directly, bit for bit, over every body of every
2353 /// `.nl` file in the repository by
2354 /// `pounce-cli/tests/quad_parse_differential.rs`.
2355 ///
2356 /// Two things it must reproduce and not merely approximate:
2357 ///
2358 /// * **The exactness rule.** `is_expanded_quadratic` admits a body only
2359 /// when reading it as `½xᵀHx + aᵀx + c` repeats the additions the
2360 /// writer already wrote — expanding a *factored* form cancels, which
2361 /// is what took `airport.nl` from 16 to 300 iterations in Q4. Here
2362 /// that rule is structural rather than a separate pass: `+`/`-` are
2363 /// the spine, everything under a `*`, `/` or `^` is read in monomial
2364 /// mode, and a `+` seen in monomial mode gives up.
2365 /// * **The association.** `recognize_expr` folds an `o54` sumlist from
2366 /// the *first* operand to the last, which is also the order the AD
2367 /// tape sums in, so this folds the same way — operands arrive here in
2368 /// file order and are folded as they arrive. Summation order is not
2369 /// observable on distinct monomials and is exactly observable on
2370 /// repeated ones.
2371 ///
2372 /// On `None` the cursor is left wherever it stopped; the caller rewinds.
2373 fn parse_expr_quadratic(&mut self) -> Option<(Quad2, Vec<u32>, u32)> {
2374 let mut frames: Vec<QFrame> = Vec::new();
2375 let mut vals: Vec<Quad2> = Vec::new();
2376 let mut vars: Vec<u32> = Vec::new();
2377 // Deepest node of the tree these tokens describe. A leaf sits under
2378 // `frames.len()` operators, and a CSE reference carries its body's
2379 // depth under it — the same convention `pounce-py`'s `expr_depth`
2380 // uses, because that is who reads the answer.
2381 let mut depth: u32 = 0;
2382 let note_leaf = |frames: &[QFrame], depth: &mut u32, below: u32| {
2383 let d = u32::try_from(frames.len()).unwrap_or(u32::MAX);
2384 *depth = (*depth).max(d.saturating_add(1).saturating_add(below));
2385 };
2386
2387 loop {
2388 let mono = frames.last().is_some_and(|f| f.mono);
2389 // A `^` base must be atomic — `Const`, `Var` or a `Neg` — or the
2390 // body is a factored form dressed as a monomial. `is_monomial`
2391 // applies the same test to `Pow`'s left operand.
2392 let pow_base = matches!(
2393 frames.last(),
2394 Some(QFrame {
2395 op: QOp::Pow,
2396 remaining: 2,
2397 ..
2398 }) | Some(QFrame {
2399 op: QOp::Square,
2400 remaining: 1,
2401 ..
2402 })
2403 );
2404
2405 let raw = self.next_line()?;
2406 let tok = strip_comment(raw).trim();
2407 let first = tok.chars().next()?;
2408 match first {
2409 'n' => {
2410 // A constant base is atomic; `is_monomial` accepts it.
2411 // A non-finite literal bails out of the fast path so the
2412 // general parser reaches it and reports the error rather
2413 // than folding it into a quadratic (gh #847).
2414 let v: Number = tok[1..]
2415 .trim()
2416 .parse()
2417 .ok()
2418 .filter(|v: &Number| v.is_finite())?;
2419 note_leaf(&frames, &mut depth, 0);
2420 vals.push(Quad2::of_constant(v));
2421 }
2422 'v' => {
2423 let i: usize = tok[1..].trim().parse().ok()?;
2424 if i < self.n {
2425 note_leaf(&frames, &mut depth, 0);
2426 vars.push(u32::try_from(i).ok()?);
2427 vals.push(Quad2::of_var(i));
2428 } else {
2429 // A CSE reference lowers to `Expr::Cse`, which is
2430 // *not* one of the shapes `is_monomial` accepts as a
2431 // power's base.
2432 if pow_base {
2433 return None;
2434 }
2435 let local = i.checked_sub(self.n)?;
2436 let ok = if mono {
2437 *self.cse_mono_ok.get(local)?
2438 } else {
2439 *self.cse_sum_ok.get(local)?
2440 };
2441 if !ok {
2442 return None;
2443 }
2444 let form = self.cse_quad.get(local)?.clone()?;
2445 note_leaf(&frames, &mut depth, *self.cse_depth.get(local)?);
2446 vars.extend_from_slice(self.cse_vars.get(local)?);
2447 vals.push(form);
2448 }
2449 }
2450 'o' => {
2451 let code: i32 = tok[1..].trim().parse().ok()?;
2452 if pow_base && code != 16 {
2453 return None;
2454 }
2455 let (op, arity, child_mono) = match code {
2456 // The sum spine. Inside a monomial there is no such
2457 // thing, and the body is a factored form.
2458 0 if !mono => (QOp::Add, 2, false),
2459 1 if !mono => (QOp::Sub, 2, false),
2460 16 => (QOp::Neg, 1, mono),
2461 54 if !mono => {
2462 let count_line = self.next_data_line().ok()?;
2463 let count: usize =
2464 count_line.split_whitespace().next()?.parse().ok()?;
2465 (QOp::Sum(count), count, false)
2466 }
2467 // Monomial operators: everything below them is read
2468 // in monomial mode.
2469 2 => (QOp::Mul, 2, true),
2470 3 => (QOp::Div, 2, true),
2471 5 | 81 | 83 => (QOp::Pow, 2, true),
2472 82 => (QOp::Square, 1, true),
2473 // Transcendentals, comparisons, conditionals,
2474 // min/max lists, and `+`/`-` under a monomial.
2475 _ => return None,
2476 };
2477 if arity == 0 {
2478 // An empty `o54` is the empty sum: zero.
2479 note_leaf(&frames, &mut depth, 0);
2480 vals.push(Quad2::default());
2481 } else {
2482 frames.push(QFrame {
2483 op,
2484 remaining: arity,
2485 mono: child_mono,
2486 });
2487 continue;
2488 }
2489 }
2490 // `f` (imported function call), `h`, and anything else.
2491 _ => return None,
2492 }
2493
2494 // A value has just been produced. Close every frame it completes.
2495 while let Some(f) = frames.last_mut() {
2496 f.remaining -= 1;
2497 if f.remaining > 0 {
2498 break;
2499 }
2500 let f = frames.pop()?;
2501 let combined = apply_quad_op(f.op, &mut vals)?;
2502 vals.push(combined);
2503 }
2504 if frames.is_empty() {
2505 break;
2506 }
2507 }
2508
2509 if vals.len() != 1 {
2510 return None;
2511 }
2512 vars.sort_unstable();
2513 vars.dedup();
2514 Some((vals.pop()?, vars, depth))
2515 }
2516
2517 fn parse_expr(&mut self) -> Result<Expr, String> {
2518 // gh#986: a recursion guard that fires before the stack does. A
2519 // stack overflow is an abort, not an error return, so the reader
2520 // refuses past `MAX_PARSE_DEPTH` levels with a clean message at any
2521 // input depth instead of relying on the caller's stack size.
2522 if self.parse_depth >= self.max_depth {
2523 return Err(format!(
2524 "the model nests an expression more than {} levels deep, which the \
2525 recursive reader refuses rather than overflow the stack (a long \
2526 left-deep `o0` sum is read flat and does not count; deep unary or \
2527 `o2`/`o3` nesting does)",
2528 self.max_depth
2529 ));
2530 }
2531 self.parse_depth += 1;
2532 let r = self.parse_expr_inner();
2533 self.parse_depth -= 1;
2534 r
2535 }
2536
2537 fn parse_expr_inner(&mut self) -> Result<Expr, String> {
2538 let raw = self
2539 .next_line()
2540 .ok_or_else(|| "expected expression token".to_string())?;
2541 // Borrowed, not owned: this runs once per node of every
2542 // expression tree in the file, so an owned `String` here is one
2543 // heap allocation per tape op in the whole model.
2544 let tok = strip_comment(raw).trim();
2545 if tok.is_empty() {
2546 return Err("empty expression token".into());
2547 }
2548 let first = tok.chars().next().ok_or("empty expression token")?;
2549 match first {
2550 'n' => {
2551 let v: Number = tok[1..]
2552 .trim()
2553 .parse()
2554 .map_err(|e| format!("n value: {e}"))?;
2555 // A `nan` literal in the objective body reached the answer
2556 // itself: `Objective: nan` under `Solve_Succeeded` and exit
2557 // code 0 (gh #847).
2558 Ok(Expr::Const(finite_or_err("numeric literal", v)?))
2559 }
2560 'v' => {
2561 let i: usize = tok[1..]
2562 .trim()
2563 .parse()
2564 .map_err(|e| format!("v index: {e}"))?;
2565 Ok(self.var_or_cse(i)?)
2566 }
2567 'o' => {
2568 let code: i32 = tok[1..]
2569 .trim()
2570 .parse()
2571 .map_err(|e| format!("opcode: {e}"))?;
2572 self.parse_opcode(code)
2573 }
2574 'f' => {
2575 // AMPL imported (external) function call: `f<id> <nargs>`
2576 // followed by nargs child expressions (or string literals).
2577 let rest = &tok[1..];
2578 let mut parts = rest.split_whitespace();
2579 let id_str = parts
2580 .next()
2581 .ok_or_else(|| format!("missing function id in '{tok}'"))?;
2582 let nargs_str = parts
2583 .next()
2584 .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
2585 let id: usize = id_str
2586 .parse()
2587 .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
2588 let nargs: usize = nargs_str
2589 .parse()
2590 .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
2591 let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
2592 for _ in 0..nargs {
2593 args.push(self.parse_funcall_arg()?);
2594 }
2595 Ok(Expr::Funcall { id, args })
2596 }
2597 't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
2598 other => Err(format!(
2599 "unexpected expression token start '{other}': '{tok}'"
2600 )),
2601 }
2602 }
2603
2604 /// Parse one argument to an AMPL imported function. An argument
2605 /// is either a normal expression (real-valued) or a string literal
2606 /// in the form `h<len>:<chars>`. AMPL emits string args only when the
2607 /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
2608 /// or a parameters-directory path for IDAES Helmholtz functions).
2609 fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
2610 // Peek the next non-blank line so we can route `h...` differently.
2611 let saved = self.pos;
2612 let raw = self
2613 .next_line()
2614 .ok_or_else(|| "expected funcall argument".to_string())?;
2615 // A string arg is a Hollerith literal `h<len>:<chars>` where the
2616 // chars are *exactly* `<len>` bytes and may legitimately contain
2617 // '#'. We must NOT strip a trailing comment before extracting the
2618 // content (that would truncate e.g. a path like `a#b`), and we
2619 // honor the declared length rather than splitting loosely on ':'.
2620 // Detect the form from the leading non-blank char of the raw line;
2621 // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
2622 let lead = raw.trim_start();
2623 if let Some(after_h) = lead.strip_prefix('h') {
2624 let colon = after_h
2625 .find(':')
2626 .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
2627 let len: usize = after_h[..colon]
2628 .trim()
2629 .parse()
2630 .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
2631 let chars = &after_h[colon + 1..];
2632 if chars.len() < len {
2633 return Err(format!(
2634 "Hollerith string shorter than declared length {len}: {chars:?}"
2635 ));
2636 }
2637 // Take exactly `len` bytes; anything past it (trailing
2638 // whitespace, a real comment) is not part of the string.
2639 if !chars.is_char_boundary(len) {
2640 return Err(format!(
2641 "Hollerith length {len} splits a multibyte char in {chars:?}"
2642 ));
2643 }
2644 Ok(FuncallArg::Str(chars[..len].to_string()))
2645 } else {
2646 // Rewind: parse_expr re-consumes the line we just peeked.
2647 self.pos = saved;
2648 Ok(FuncallArg::Real(self.parse_expr()?))
2649 }
2650 }
2651
2652 fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
2653 match code {
2654 0 => {
2655 // gh#986: a left-deep run of binary `o0` — `o0 o0 o0 … a b c d`,
2656 // what AMPL and discopt write for a long sum — is read as one
2657 // n-ary `Sum`, so the tree is one level deep however many terms
2658 // there are. Taken only past `SUM_CHAIN_FLATTEN_MIN` links, so
2659 // an ordinary model keeps exactly the tree it always had.
2660 // Each `o0`'s first child is the next line, so the `k + 1`
2661 // operands that follow the run are, in order, the leaves of
2662 // `((a + b) + c) + d`; `Sum` adds them left to right, the same
2663 // order of operations.
2664 let after_first = self.pos;
2665 let mut k = 1usize;
2666 loop {
2667 let before = self.pos;
2668 match self.next_line() {
2669 Some(l) if strip_comment(l).trim() == "o0" => k += 1,
2670 _ => {
2671 self.pos = before;
2672 break;
2673 }
2674 }
2675 }
2676 if k > SUM_CHAIN_FLATTEN_MIN {
2677 let mut terms = Vec::with_capacity(k + 1);
2678 for _ in 0..=k {
2679 terms.push(self.parse_expr()?);
2680 }
2681 return Ok(Expr::Sum(terms));
2682 }
2683 self.pos = after_first;
2684 let a = self.parse_expr()?;
2685 let b = self.parse_expr()?;
2686 Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
2687 }
2688 1 => {
2689 let a = self.parse_expr()?;
2690 let b = self.parse_expr()?;
2691 Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
2692 }
2693 2 => {
2694 let a = self.parse_expr()?;
2695 let b = self.parse_expr()?;
2696 Ok(Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b)))
2697 }
2698 3 => {
2699 let a = self.parse_expr()?;
2700 let b = self.parse_expr()?;
2701 Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
2702 }
2703 5 => {
2704 let a = self.parse_expr()?;
2705 let b = self.parse_expr()?;
2706 Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
2707 }
2708 15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
2709 16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
2710 39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
2711 41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
2712 42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
2713 43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
2714 44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
2715 46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
2716 38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
2717 49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
2718 53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
2719 40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
2720 45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
2721 37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
2722 51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
2723 52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
2724 50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
2725 47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
2726 // atan2(y, x): binary, operand order `y` then `x`.
2727 48 => {
2728 let a = self.parse_expr()?;
2729 let b = self.parse_expr()?;
2730 Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
2731 }
2732 // Relational comparisons (binary). Operand order is
2733 // `left OP right`.
2734 22 => self.parse_compare(CmpOp::Lt),
2735 23 => self.parse_compare(CmpOp::Le),
2736 24 => self.parse_compare(CmpOp::Eq),
2737 28 => self.parse_compare(CmpOp::Ge),
2738 29 => self.parse_compare(CmpOp::Gt),
2739 30 => self.parse_compare(CmpOp::Ne),
2740 // Logical connectives.
2741 20 => {
2742 let a = self.parse_expr()?;
2743 let b = self.parse_expr()?;
2744 Ok(Expr::Or(Box::new(a), Box::new(b)))
2745 }
2746 21 => {
2747 let a = self.parse_expr()?;
2748 let b = self.parse_expr()?;
2749 Ok(Expr::And(Box::new(a), Box::new(b)))
2750 }
2751 34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
2752 // if-then-else: condition, then-value, else-value.
2753 35 => {
2754 let cond = self.parse_expr()?;
2755 let then_ = self.parse_expr()?;
2756 let else_ = self.parse_expr()?;
2757 Ok(Expr::Cond {
2758 cond: Box::new(cond),
2759 then_: Box::new(then_),
2760 else_: Box::new(else_),
2761 })
2762 }
2763 54 => {
2764 // Variadic sum: next data line gives the count.
2765 let count_line = self.next_data_line()?;
2766 let count: usize = count_line
2767 .split_whitespace()
2768 .next()
2769 .ok_or_else(|| "missing variadic count".to_string())?
2770 .parse()
2771 .map_err(|e| format!("variadic count: {e}"))?;
2772 let mut args = Vec::with_capacity(count);
2773 for _ in 0..count {
2774 args.push(self.parse_expr()?);
2775 }
2776 Ok(Expr::Sum(args))
2777 }
2778 // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
2779 // a count data line followed by that many operands.
2780 11 | 12 => {
2781 let count_line = self.next_data_line()?;
2782 let count: usize = count_line
2783 .split_whitespace()
2784 .next()
2785 .ok_or_else(|| "missing min/max list count".to_string())?
2786 .parse()
2787 .map_err(|e| format!("min/max list count: {e}"))?;
2788 let mut args = Vec::with_capacity(count);
2789 for _ in 0..count {
2790 args.push(self.parse_expr()?);
2791 }
2792 if code == 11 {
2793 Ok(Expr::MinList(args))
2794 } else {
2795 Ok(Expr::MaxList(args))
2796 }
2797 }
2798 // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
2799 // emits these in place of the general `o5` (OPPOW) as a hint that
2800 // one operand is constant. The distinction exists because an
2801 // integer / half-integer constant power is evaluated by a
2802 // mul/sqrt chain that stays real for a negative base, whereas the
2803 // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
2804 // they read exactly like `o5`, so they lower to the same `Pow` AST
2805 // and reuse the existing constant-power tape lowering (see
2806 // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
2807 // against the ASL reader and the `ampl/mp` opcode table:
2808 // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
2809 // unary with an implicit exponent of 2.
2810 //
2811 // o81 OP1POW: `base ^ (const exponent)` — binary, operands
2812 // `base` then `exp` (the exponent is a numeric node here).
2813 81 => {
2814 let base = self.parse_expr()?;
2815 let exp = self.parse_expr()?;
2816 Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2817 }
2818 // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
2819 82 => {
2820 let base = self.parse_expr()?;
2821 Ok(Expr::Binary(
2822 BinOp::Pow,
2823 Box::new(base),
2824 Box::new(Expr::Const(2.0)),
2825 ))
2826 }
2827 // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
2828 // (the numeric node) then `exp`.
2829 83 => {
2830 let base = self.parse_expr()?;
2831 let exp = self.parse_expr()?;
2832 Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2833 }
2834 other => Err(format!("unsupported opcode o{other}")),
2835 }
2836 }
2837
2838 /// Parse the two operands of a relational opcode into an
2839 /// [`Expr::Compare`]. Operand order is `left OP right`.
2840 fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
2841 let a = self.parse_expr()?;
2842 let b = self.parse_expr()?;
2843 Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
2844 }
2845
2846 /// Resolve a `v<i>` token into either a plain variable reference
2847 /// (`i < n`) or a shared CSE reference (`i >= n`).
2848 fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
2849 if i < self.n {
2850 Ok(Expr::Var(i))
2851 } else {
2852 let local = i - self.n;
2853 self.cses
2854 .get(local)
2855 .map(|rc| Expr::Cse(rc.clone()))
2856 .ok_or_else(|| {
2857 format!(
2858 "v{i} references CSE {local} but only {} have been defined",
2859 self.cses.len()
2860 )
2861 })
2862 }
2863 }
2864
2865 /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
2866 /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
2867 /// CSEs are numbered starting at `n` and must appear in order.
2868 fn parse_v_segment(&mut self) -> Result<(), String> {
2869 let (hdr, _) = self.eat_segment_header()?;
2870 let parts: Vec<&str> = hdr.split_whitespace().collect();
2871 if parts.len() < 2 {
2872 return Err(format!("malformed V-segment header: {hdr}"));
2873 }
2874 let cse_idx = parse_segment_index(parts[0], 'V')?;
2875 let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
2876 // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
2877 let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
2878 for _ in 0..nlin {
2879 let line = self.next_data_line()?;
2880 let (var, coef) = parse_var_coef(line)?;
2881 linear.push((var, coef));
2882 }
2883 let nonlin = self.parse_expr()?;
2884 // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
2885 // reference earlier CSEs as well as plain variables.
2886 let mut combined = nonlin;
2887 for (var, coef) in linear {
2888 let v_expr = self.var_or_cse(var)?;
2889 let term = if coef == 1.0 {
2890 v_expr
2891 } else {
2892 Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
2893 };
2894 combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
2895 }
2896 if cse_idx < self.n {
2897 return Err(format!("V{cse_idx} below n={}", self.n));
2898 }
2899 let local = cse_idx - self.n;
2900 if local != self.cses.len() {
2901 return Err(format!(
2902 "V-segment index V{cse_idx} out of order; expected V{}",
2903 self.n + self.cses.len()
2904 ));
2905 }
2906 // What a reference to this body would mean to the streaming
2907 // recognizer, answered once here rather than per reference. The
2908 // `V` tree exists at this point, so these are the *same* functions
2909 // `NlTnlp` applies to a whole row — the parse-time recognizer never
2910 // gets a second opinion about a CSE.
2911 // gh#986 review: bound the chain before any pass walks through it.
2912 let depth = self.check_effective_depth(&combined)?;
2913 if self.quad_enabled {
2914 // gh#986 review: every earlier body's verdicts are already
2915 // known, so each pass reads them instead of re-walking the chain
2916 // of defined variables below this one (quadratic over a long
2917 // chain otherwise).
2918 let idx = &self.cse_index;
2919 let (quad, sum_ok, mono_ok) = (&self.cse_quad, &self.cse_sum_ok, &self.cse_mono_ok);
2920 let quad_seed = |k: *const Expr| idx.get(&k).map(|&i| quad[i].clone());
2921 let shape_seed = |k: *const Expr, mono: bool| {
2922 idx.get(&k)
2923 .map(|&i| if mono { mono_ok[i] } else { sum_ok[i] })
2924 };
2925 let q = crate::nl_quadratic::recognize_expr_seeded(&combined, &quad_seed);
2926 let s_ok = crate::nl_quadratic::is_expanded_quadratic_seeded(&combined, &shape_seed);
2927 let m_ok = crate::nl_quadratic::is_monomial_expr_seeded(&combined, &shape_seed);
2928 let mut vars: BTreeSet<usize> = BTreeSet::new();
2929 collect_vars_seeded(&combined, &mut vars, &self.cse_index, &self.cse_vars);
2930 self.cse_quad.push(q);
2931 self.cse_sum_ok.push(s_ok);
2932 self.cse_mono_ok.push(m_ok);
2933 self.cse_vars
2934 .push(vars.into_iter().map(|v| v as u32).collect());
2935 self.cse_depth.push(depth);
2936 }
2937 let body = Arc::new(combined);
2938 self.cse_index.insert(Arc::as_ptr(&body), self.cses.len());
2939 self.cse_depth_memo.insert(Arc::as_ptr(&body), depth);
2940 self.cses.push(body);
2941 Ok(())
2942 }
2943}
2944
2945/// Combine the operands of one recognized operator, mirroring
2946/// [`crate::nl_quadratic::recognize_expr`]'s `Apply` arm term for term.
2947///
2948/// Every difference from that function is a difference in the coefficients
2949/// this parser stores, so there is nothing here that is "equivalent but
2950/// tidier": the division scales by the reciprocal because that is what the
2951/// tree walk does, and the sumlist folds back to front for the same reason.
2952/// Nesting depth of a `V`-segment body, leaf = 1, a `Cse` reference
2953/// counting one level above its body — the convention `pounce-py`'s
2954/// `expr_depth` uses, since that is the guard the answer feeds.
2955///
2956/// Recursive, and safe to be: it recurses only through the syntax of `e`,
2957/// which [`Parser::parse_expr`] has just built *recursively* on this same
2958/// stack, so a frame that fits the parser fits this. A `Cse` reference is
2959/// read from `memo` (every `V` body is memoized when its segment is parsed,
2960/// and a body can only reference earlier ones), so the walk never descends a
2961/// chain of defined variables: linear in `e`, where it used to be linear in
2962/// the whole chain on every `V` segment -- quadratic over a long chain, and
2963/// as deep as it (gh#986 review).
2964fn expr_tree_depth(e: &Expr, memo: &std::collections::HashMap<*const Expr, u32>) -> u32 {
2965 let deepest = |kids: &mut dyn Iterator<Item = &Expr>| {
2966 kids.fold(0u32, |acc, k| acc.max(expr_tree_depth(k, memo)))
2967 };
2968 1 + match e {
2969 Expr::Const(_) | Expr::Var(_) => 0,
2970 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
2971 deepest(&mut [&**a, &**b].into_iter())
2972 }
2973 Expr::Unary(_, a) | Expr::Not(a) => expr_tree_depth(a, memo),
2974 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => deepest(&mut args.iter()),
2975 Expr::Cond { cond, then_, else_ } => {
2976 deepest(&mut [&**cond, &**then_, &**else_].into_iter())
2977 }
2978 Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
2979 FuncallArg::Real(inner) => Some(inner),
2980 FuncallArg::Str(_) => None,
2981 })),
2982 Expr::Cse(body) => match memo.get(&Arc::as_ptr(body)) {
2983 Some(&d) => d,
2984 None => expr_tree_depth(body, memo),
2985 },
2986 }
2987}
2988
2989fn apply_quad_op(op: QOp, vals: &mut Vec<Quad2>) -> Option<Quad2> {
2990 let pop2 = |vals: &mut Vec<Quad2>| -> Option<(Quad2, Quad2)> {
2991 let b = vals.pop()?;
2992 let a = vals.pop()?;
2993 Some((a, b))
2994 };
2995 Some(match op {
2996 QOp::Sum(n) => {
2997 let at = vals.len().checked_sub(n)?;
2998 let mut acc = Quad2::default();
2999 // Operands arrive in file order, so `drain` already yields them
3000 // front to back — the order `recognize_expr` folds in, and the
3001 // order the AD tape sums in. Do not reverse this: floating-point
3002 // addition is not associative, and on repeated monomials the two
3003 // orders do not agree bit for bit.
3004 for p in vals.drain(at..) {
3005 acc = Quad2::add(acc, p);
3006 }
3007 acc
3008 }
3009 QOp::Neg => vals.pop()?.neg(),
3010 QOp::Add => {
3011 let (a, b) = pop2(vals)?;
3012 Quad2::add(a, b)
3013 }
3014 QOp::Sub => {
3015 let (a, b) = pop2(vals)?;
3016 Quad2::add(a, b.neg())
3017 }
3018 QOp::Mul => {
3019 let (a, b) = pop2(vals)?;
3020 a.mul(&b)?
3021 }
3022 QOp::Div => {
3023 let (a, b) = pop2(vals)?;
3024 let d = b.as_constant()?;
3025 if d == 0.0 {
3026 return None;
3027 }
3028 let mut out = a.div_by_constant(d);
3029 out.absorb_flags(&b);
3030 out
3031 }
3032 QOp::Pow => {
3033 let (a, b) = pop2(vals)?;
3034 let exp = b.as_constant()?;
3035 let mut out = if exp == 0.0 {
3036 Quad2::of_constant(1.0)
3037 } else if exp == 1.0 {
3038 a
3039 } else if exp == 2.0 {
3040 a.mul(&a)?
3041 } else {
3042 return None;
3043 };
3044 // The exponent is read out of a form with `as_constant`, which
3045 // leaves its flags behind; `Div` above does the same.
3046 out.absorb_flags(&b);
3047 out
3048 }
3049 // `o82` is `Pow(base, Const(2.0))` with the exponent left implicit,
3050 // so it takes the `exp == 2.0` branch above.
3051 QOp::Square => {
3052 let a = vals.pop()?;
3053 a.mul(&a)?
3054 }
3055 })
3056}
3057
3058/// Re-parse one recognized body from the bytes it was recognized from.
3059///
3060/// The point is that this is the *same* function the original parse ran, on
3061/// the *same* bytes, with the same `n` and the same `V`-segment bodies — so
3062/// the tree it returns is the tree that parse would have built, down to the
3063/// `Arc` identities that `HybridTape::build_multi` keys CSE sharing on.
3064/// Deriving an equivalent tree from the stored coefficients instead would
3065/// be a different tree, evaluated by a different tape, and this phase would
3066/// stop being invisible from outside.
3067fn parse_body_fragment(txt: &str, n: usize, cses: &[Arc<Expr>]) -> Result<Expr, String> {
3068 let mut p = Parser::new(txt, false);
3069 p.n = n;
3070 p.cses = cses.to_vec();
3071 p.parse_expr()
3072}
3073
3074fn strip_comment(s: &str) -> &str {
3075 match s.find('#') {
3076 Some(i) => &s[..i],
3077 None => s,
3078 }
3079}
3080
3081fn split_comment(s: &str) -> (&str, &str) {
3082 match s.find('#') {
3083 Some(i) => (&s[..i], &s[i + 1..]),
3084 None => (s, ""),
3085 }
3086}
3087
3088// --------------------------------------------------------------------
3089// Expression evaluation and gradient (tree walkers, kept for tests).
3090// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
3091// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
3092// --------------------------------------------------------------------
3093
3094/// Forward-mode value evaluation.
3095pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
3096 match e {
3097 Expr::Const(c) => *c,
3098 Expr::Var(i) => x[*i],
3099 Expr::Binary(op, a, b) => {
3100 let va = eval_expr(a, x);
3101 let vb = eval_expr(b, x);
3102 match op {
3103 BinOp::Add => va + vb,
3104 BinOp::Sub => va - vb,
3105 BinOp::Mul => va * vb,
3106 BinOp::Div => va / vb,
3107 BinOp::Pow => va.powf(vb),
3108 BinOp::Atan2 => va.atan2(vb),
3109 BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
3110 }
3111 }
3112 Expr::Unary(op, a) => {
3113 let va = eval_expr(a, x);
3114 match op {
3115 UnaryOp::Neg => -va,
3116 UnaryOp::Sqrt => va.sqrt(),
3117 UnaryOp::Log => va.ln(),
3118 UnaryOp::Log10 => va.log10(),
3119 UnaryOp::Exp => va.exp(),
3120 UnaryOp::Abs => va.abs(),
3121 UnaryOp::Sin => va.sin(),
3122 UnaryOp::Cos => va.cos(),
3123 UnaryOp::Tan => va.tan(),
3124 UnaryOp::Atan => va.atan(),
3125 UnaryOp::Acos => va.acos(),
3126 UnaryOp::Sinh => va.sinh(),
3127 UnaryOp::Cosh => va.cosh(),
3128 UnaryOp::Tanh => va.tanh(),
3129 UnaryOp::Asin => va.asin(),
3130 UnaryOp::Acosh => va.acosh(),
3131 UnaryOp::Asinh => va.asinh(),
3132 UnaryOp::Atanh => va.atanh(),
3133 UnaryOp::Erf => crate::nl_tape::erf(va),
3134 UnaryOp::XLogX => crate::nl_tape::xlogx(va),
3135 }
3136 }
3137 Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
3138 Expr::MinList(args) => args
3139 .iter()
3140 .map(|a| eval_expr(a, x))
3141 .fold(Number::INFINITY, Number::min),
3142 Expr::MaxList(args) => args
3143 .iter()
3144 .map(|a| eval_expr(a, x))
3145 .fold(Number::NEG_INFINITY, Number::max),
3146 Expr::Compare(op, a, b) => {
3147 let va = eval_expr(a, x);
3148 let vb = eval_expr(b, x);
3149 let truth = match op {
3150 CmpOp::Lt => va < vb,
3151 CmpOp::Le => va <= vb,
3152 CmpOp::Eq => va == vb,
3153 CmpOp::Ge => va >= vb,
3154 CmpOp::Gt => va > vb,
3155 CmpOp::Ne => va != vb,
3156 };
3157 if truth { 1.0 } else { 0.0 }
3158 }
3159 Expr::And(a, b) => {
3160 if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
3161 1.0
3162 } else {
3163 0.0
3164 }
3165 }
3166 Expr::Or(a, b) => {
3167 if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
3168 1.0
3169 } else {
3170 0.0
3171 }
3172 }
3173 Expr::Not(a) => {
3174 if eval_expr(a, x) == 0.0 {
3175 1.0
3176 } else {
3177 0.0
3178 }
3179 }
3180 Expr::Cond { cond, then_, else_ } => {
3181 if eval_expr(cond, x) != 0.0 {
3182 eval_expr(then_, x)
3183 } else {
3184 eval_expr(else_, x)
3185 }
3186 }
3187 Expr::Cse(body) => eval_expr(body, x),
3188 Expr::Funcall { .. } => panic!(
3189 "eval_expr: AMPL imported function called without an external resolver; \
3190 evaluate through the tape AD path (Tape::build_with_externals) instead"
3191 ),
3192 }
3193}
3194
3195/// Index of the active operand of an n-ary min (`want_min = true`) or
3196/// max (`want_min = false`) list at point `x`: the smallest / largest
3197/// value, with ties resolved to the first such operand (the
3198/// conventional subgradient choice). Returns `None` for an empty list.
3199fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
3200 let mut best: Option<(usize, Number)> = None;
3201 for (i, a) in args.iter().enumerate() {
3202 let v = eval_expr(a, x);
3203 match best {
3204 None => best = Some((i, v)),
3205 Some((_, bv)) => {
3206 // Strict comparison keeps the FIRST extremal operand on
3207 // ties, matching the subgradient convention used by Abs
3208 // and Select elsewhere in the tape.
3209 if (want_min && v < bv) || (!want_min && v > bv) {
3210 best = Some((i, v));
3211 }
3212 }
3213 }
3214 }
3215 best.map(|(i, _)| i)
3216}
3217
3218/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
3219pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
3220 match e {
3221 Expr::Const(_) => {}
3222 Expr::Var(i) => grad[*i] += seed,
3223 Expr::Binary(op, a, b) => {
3224 let va = eval_expr(a, x);
3225 let vb = eval_expr(b, x);
3226 match op {
3227 BinOp::Add => {
3228 grad_expr(a, x, seed, grad);
3229 grad_expr(b, x, seed, grad);
3230 }
3231 BinOp::Sub => {
3232 grad_expr(a, x, seed, grad);
3233 grad_expr(b, x, -seed, grad);
3234 }
3235 BinOp::Mul => {
3236 grad_expr(a, x, seed * vb, grad);
3237 grad_expr(b, x, seed * va, grad);
3238 }
3239 BinOp::Div => {
3240 grad_expr(a, x, seed / vb, grad);
3241 grad_expr(b, x, -seed * va / (vb * vb), grad);
3242 }
3243 BinOp::Pow => {
3244 // d/da: b * a^(b-1)
3245 let dpa = vb * va.powf(vb - 1.0);
3246 grad_expr(a, x, seed * dpa, grad);
3247 // d/db: a^b * ln(a) (only valid for a>0; simple branch)
3248 if va > 0.0 {
3249 let dpb = va.powf(vb) * va.ln();
3250 grad_expr(b, x, seed * dpb, grad);
3251 }
3252 }
3253 BinOp::Atan2 => {
3254 // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
3255 let d = va * va + vb * vb;
3256 grad_expr(a, x, seed * vb / d, grad);
3257 grad_expr(b, x, -seed * va / d, grad);
3258 }
3259 BinOp::CEntropy => {
3260 grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
3261 grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
3262 }
3263 }
3264 }
3265 Expr::Unary(op, a) => {
3266 let va = eval_expr(a, x);
3267 let d = match op {
3268 UnaryOp::Neg => -1.0,
3269 UnaryOp::Sqrt => 0.5 / va.sqrt(),
3270 UnaryOp::Log => 1.0 / va,
3271 UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
3272 UnaryOp::Exp => va.exp(),
3273 UnaryOp::Abs => {
3274 if va > 0.0 {
3275 1.0
3276 } else if va < 0.0 {
3277 -1.0
3278 } else {
3279 0.0
3280 }
3281 }
3282 UnaryOp::Sin => va.cos(),
3283 UnaryOp::Cos => -va.sin(),
3284 UnaryOp::Tan => {
3285 let t = va.tan();
3286 1.0 + t * t
3287 }
3288 UnaryOp::Atan => 1.0 / (1.0 + va * va),
3289 UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
3290 UnaryOp::Sinh => va.cosh(),
3291 UnaryOp::Cosh => va.sinh(),
3292 UnaryOp::Tanh => {
3293 let t = va.tanh();
3294 1.0 - t * t
3295 }
3296 UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
3297 UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
3298 UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
3299 UnaryOp::Atanh => 1.0 / (1.0 - va * va),
3300 UnaryOp::Erf => crate::nl_tape::erf_d1(va),
3301 UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
3302 };
3303 grad_expr(a, x, seed * d, grad);
3304 }
3305 Expr::Sum(args) => {
3306 for arg in args {
3307 grad_expr(arg, x, seed, grad);
3308 }
3309 }
3310 // min/max are piecewise linear: the seed flows only through the
3311 // currently-active (smallest / largest) operand — a subgradient.
3312 // Ties resolve to the first such operand. Empty list: no operand,
3313 // no derivative (matches the ±inf eval fold).
3314 Expr::MinList(args) => {
3315 if let Some(k) = argmin_argmax(args, x, true) {
3316 grad_expr(&args[k], x, seed, grad);
3317 }
3318 }
3319 Expr::MaxList(args) => {
3320 if let Some(k) = argmin_argmax(args, x, false) {
3321 grad_expr(&args[k], x, seed, grad);
3322 }
3323 }
3324 // Comparisons and logical connectives are piecewise constant:
3325 // zero derivative, so no seed propagates into their operands.
3326 Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3327 // if-then-else: differentiate only the active branch. The
3328 // branch-switch discontinuity contributes no derivative.
3329 Expr::Cond { cond, then_, else_ } => {
3330 if eval_expr(cond, x) != 0.0 {
3331 grad_expr(then_, x, seed, grad);
3332 } else {
3333 grad_expr(else_, x, seed, grad);
3334 }
3335 }
3336 Expr::Cse(body) => grad_expr(body, x, seed, grad),
3337 Expr::Funcall { .. } => {
3338 panic!("grad_expr: AMPL imported function called without an external resolver")
3339 }
3340 }
3341}
3342
3343/// Walk `e` and insert every `Var(i)` index into `out`.
3344///
3345/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3346/// identity. Without that this is Θ(2^depth) on a DAG that shares
3347/// subexpressions — each reference re-walks the whole body — and presolve
3348/// calls this on every solve (`get_variables_linearity`). Skipping a
3349/// repeat visit cannot change the answer: `out` is a set, and a second
3350/// walk of the same body inserts exactly the indices the first already did.
3351/// [`collect_vars`] over a `V` body whose earlier `Cse` references already
3352/// have their variable sets in `cse_vars` (indexed through `index`): those are
3353/// read, not re-walked (gh#986 review). The recursion is through `e`'s own
3354/// syntax only, which the parser has just built on this stack.
3355fn collect_vars_seeded(
3356 e: &Expr,
3357 out: &mut BTreeSet<usize>,
3358 index: &std::collections::HashMap<*const Expr, usize>,
3359 cse_vars: &[Vec<u32>],
3360) {
3361 let mut kids: Vec<&Expr> = vec![e];
3362 while let Some(e) = kids.pop() {
3363 match e {
3364 Expr::Const(_) => {}
3365 Expr::Var(i) => {
3366 out.insert(*i);
3367 }
3368 Expr::Cse(body) => match index.get(&Arc::as_ptr(body)) {
3369 Some(&k) => out.extend(cse_vars[k].iter().map(|&v| v as usize)),
3370 None => collect_vars(body, out),
3371 },
3372 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3373 kids.push(a);
3374 kids.push(b);
3375 }
3376 Expr::Unary(_, a) | Expr::Not(a) => kids.push(a),
3377 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => kids.extend(args.iter()),
3378 Expr::Cond { cond, then_, else_ } => {
3379 kids.push(cond);
3380 kids.push(then_);
3381 kids.push(else_);
3382 }
3383 Expr::Funcall { args, .. } => {
3384 for a in args {
3385 if let FuncallArg::Real(e) = a {
3386 kids.push(e);
3387 }
3388 }
3389 }
3390 }
3391 }
3392}
3393
3394pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3395 // `HashSet::new` does not allocate until the first insert, so an
3396 // expression with no CSEs pays nothing for the memo.
3397 let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3398 collect_vars_memo(e, out, &mut seen);
3399}
3400
3401fn collect_vars_memo(
3402 e: &Expr,
3403 out: &mut BTreeSet<usize>,
3404 seen: &mut std::collections::HashSet<*const Expr>,
3405) {
3406 match e {
3407 Expr::Const(_) => {}
3408 Expr::Var(i) => {
3409 out.insert(*i);
3410 }
3411 Expr::Binary(_, a, b) => {
3412 collect_vars_memo(a, out, seen);
3413 collect_vars_memo(b, out, seen);
3414 }
3415 Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3416 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3417 for a in args {
3418 collect_vars_memo(a, out, seen);
3419 }
3420 }
3421 // Collect from every child, including the condition: even
3422 // though the comparison/branch-test contributes no derivative,
3423 // the variables it reads are genuinely "used" by the problem,
3424 // and being conservative here only ever adds structural zeros
3425 // to the Jacobian/Hessian (never drops a real nonzero).
3426 Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3427 collect_vars_memo(a, out, seen);
3428 collect_vars_memo(b, out, seen);
3429 }
3430 Expr::Not(a) => collect_vars_memo(a, out, seen),
3431 Expr::Cond { cond, then_, else_ } => {
3432 collect_vars_memo(cond, out, seen);
3433 collect_vars_memo(then_, out, seen);
3434 collect_vars_memo(else_, out, seen);
3435 }
3436 Expr::Cse(body) => {
3437 if seen.insert(Arc::as_ptr(body)) {
3438 collect_vars_memo(body, out, seen);
3439 }
3440 }
3441 Expr::Funcall { args, .. } => {
3442 for a in args {
3443 if let FuncallArg::Real(e) = a {
3444 collect_vars_memo(e, out, seen);
3445 }
3446 }
3447 }
3448 }
3449}
3450
3451// --------------------------------------------------------------------
3452// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3453// Jacobian, and Hessian. Built once at construction; every solve-time
3454// callback is a tape sweep, no expression-tree recursion.
3455// --------------------------------------------------------------------
3456
3457/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3458/// After a directional Hessian-vector product `compressed = H · s_c`,
3459/// the entry at row `row` came uniquely from column `col` (because
3460/// no two columns of color `c` share any nonzero row), so we
3461/// scatter `compressed[row]` into `values[hess_idx]`.
3462#[derive(Debug, Clone)]
3463struct ColorWrite {
3464 row: u32,
3465 hess_idx: u32,
3466}
3467
3468/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3469/// **shared prelude** holding every CSE body referenced by two or more
3470/// summands, evaluated once per sweep.
3471///
3472/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3473/// defined variable (`V` segment) referenced from many rows is re-emitted —
3474/// and re-evaluated — once per reference. That is invisible on most models
3475/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3476/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3477/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3478/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3479/// search. See pounce#476.
3480///
3481/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3482/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3483/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3484/// overhead the flat tapes do not, so each derivative order has to earn
3485/// its switch.
3486#[derive(Debug, Clone)]
3487struct ConHybrid {
3488 tape: HybridTape,
3489 /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3490 /// Length `m + 1`.
3491 row_start: Vec<usize>,
3492 /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3493 prelude_vals: Vec<f64>,
3494 /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3495 local_vals: Vec<f64>,
3496 /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3497 /// value arenas above. `gradient_summand` zeroes only the slots a
3498 /// summand actually reaches, so these are allocated once and reused.
3499 local_adj: Vec<f64>,
3500 prelude_adj: Vec<f64>,
3501 /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3502 /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3503 /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3504 use_for_jac: bool,
3505 /// Whether `eval_h` routes the constraint block through the shared
3506 /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3507 use_for_hess: bool,
3508 // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3509 // `try_new` after the Hessian coloring exists; cheap enough (a few
3510 // index tables plus one f64 per local op) to build whenever the
3511 // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3512 /// Forward values of every summand, packed at
3513 /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3514 /// fills it; every color then reuses the values, mirroring the flat
3515 /// path's forward-once-per-tape structure.
3516 local_vals_all: Vec<f64>,
3517 /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3518 local_off: Vec<usize>,
3519 /// Constraint row of each summand (the inverse of `row_start`), for
3520 /// the `λ[row]` weight lookup.
3521 summand_row: Vec<u32>,
3522 /// Per color: the summands whose variables fall in that color — the
3523 /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3524 /// exactly the live (color, summand) pairs.
3525 hess_color_summands: Vec<Vec<u32>>,
3526 /// Per color: the prelude slots that color's summands actually reach,
3527 /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3528 /// Both prelude sweeps run once per color, so walking the whole
3529 /// prelude each time would cost `n_colors × |prelude|` where the
3530 /// op-ratio gate assumes `|prelude|`; iterating the union of the
3531 /// color's `prelude_reach` sets makes the cost proportional to what
3532 /// is used, so the gate does not need an `n_colors` term. Stored as
3533 /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3534 /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3535 /// this struct is the one #552 made O(n²) by holding per-color dense
3536 /// arrays.
3537 hess_color_reach: Vec<u32>,
3538 hess_color_reach_off: Vec<usize>,
3539 /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3540 prelude_dot: Vec<f64>,
3541 /// First-/second-order prelude adjoint accumulators. NOT shared
3542 /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3543 /// between-colors invariant (`prelude_reverse_directional`'s
3544 /// consume-and-zero contract), while `gradient_summand` zeroes only
3545 /// the slots it is about to use and leaves them dirty afterwards —
3546 /// sharing the buffer would seed a later `eval_h` with a stale
3547 /// Jacobian adjoint.
3548 hess_prelude_adj: Vec<f64>,
3549 prelude_adj_dot: Vec<f64>,
3550 /// Local tangent / second-order adjoint arenas, sized to
3551 /// `tape.max_summand_ops()`.
3552 local_dot: Vec<f64>,
3553 local_adj_dot: Vec<f64>,
3554}
3555
3556/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3557/// shared-CSE prelude.
3558///
3559/// `eval_g` takes the hybrid path unconditionally because it only needs
3560/// *values*: the prelude is swept once for the whole constraint block and
3561/// the saving is the full op-count ratio. The Jacobian is different. Each
3562/// row needs its own gradient, so only the forward sweep can be shared —
3563/// the reverse sweep still walks each summand's `prelude_reach`
3564/// separately, and it pays a per-op cost the flat tape does not: a nested
3565/// `SummandOp` dispatch and an indirected walk over a reach list instead
3566/// of a straight loop over a contiguous `Vec<TapeOp>`.
3567///
3568/// So the hybrid Jacobian wins only when the shared bodies are large
3569/// enough for the halved forward sweep to outweigh that overhead.
3570/// Measured on chain models at CSE redundancy 40, varying the body size
3571/// (`eval_jac_g`, flat → hybrid):
3572///
3573/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3574/// |---|---|---|---|---|---|---|---|---|
3575/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3576///
3577/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3578/// a model that does not clearly benefit stays on the flat path. For
3579/// reference `robot_a` (#476) measures 4.03×.
3580const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3581
3582/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3583/// block through the shared-CSE prelude (issue #557).
3584///
3585/// The Hessian shares **both** second-order sweeps of the prelude, not just
3586/// the forward one: the coloring hands every summand of a color the same
3587/// seed vector, so the prelude forward tangent runs once per color, and —
3588/// because reverse-over-tangent is linear in its adjoint seeds — the
3589/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3590/// unit-weight prelude reverse sweep per color. That is why its crossover
3591/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3592/// Jacobian can only share the forward half, and per-row gradients forbid
3593/// batching its reverse sweeps at all.
3594///
3595/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3596/// bodies), varying the body size — the same protocol as the Jacobian
3597/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3598/// flat/hybrid pairs per point**, with the observed range, because
3599/// single runs on a shared machine are not reproducible to the precision
3600/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3601/// single ratio:
3602///
3603/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3604/// |---|---|---|---|---|---|---|---|---|
3605/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3606/// | 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 |
3607///
3608/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3609/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3610/// break-even and individual runs lose. Setting it there costs only a
3611/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3612/// bit-identically, so a gate placed too high is merely conservative while
3613/// one placed too low risks a real regression. For reference `robot_a`
3614/// (#476) measures 4.03×.
3615const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3616
3617// `Clone` supports the batched-solve path (pounce#126): one parsed
3618// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3619// the clone is cheap relative to a solve) and each clone gets its own
3620// bound / starting-point overrides via [`NlTnlp::variant`].
3621#[derive(Debug, Clone)]
3622pub struct NlTnlp {
3623 prob: NlProblem,
3624 /// Per-summand objective tapes (one `Tape` per top-level
3625 /// summand after `split_top_sums`).
3626 obj_tapes: Vec<Tape>,
3627 /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3628 /// one `Tape` per summand of constraint `i`.
3629 con_tapes: Vec<Vec<Tape>>,
3630 /// Constraint-block tape with a **shared** CSE prelude, used by
3631 /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3632 /// `eval_g` on the per-summand `con_tapes` above.
3633 con_hybrid: Option<ConHybrid>,
3634 /// The degree-≤2 objective and rows, evaluated from their constant
3635 /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3636 /// has an **empty** `con_tapes` entry, so every loop over the tapes
3637 /// naturally contributes nothing for it and only the sites that consult
3638 /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3639 /// `eval_h`, and `hessian_vector_products` — the last of which the design
3640 /// note's list of five omitted, and which is the one where an omission
3641 /// would be silent. Empty for a model with nothing recognized, in which
3642 /// case everything below behaves bit for bit as it did before this
3643 /// existed.
3644 quad: QuadraticStructure,
3645 /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3646 /// Empty when `quad` is — the pattern is then all tape. The coloring is
3647 /// built over this subset alone, which is what stops one dense quadratic
3648 /// block from forcing the color count to `n` for the benefit of tapes
3649 /// that no longer exist.
3650 h_tape_mask: Vec<bool>,
3651 /// Lower-triangle Hessian sparsity (row >= col), one entry per
3652 /// structurally nonzero second derivative in the Lagrangian.
3653 h_irow: Vec<i32>,
3654 h_jcol: Vec<i32>,
3655 /// Per-row sorted variable indices for the constraint Jacobian.
3656 jac_cols: Vec<Vec<usize>>,
3657 jac_nnz: usize,
3658 /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3659 /// is in color `c`, else `0.0`. Each color is a set of
3660 /// variables whose Hessian columns have pairwise-disjoint
3661 /// nonzero rows; one directional H·s product per color
3662 /// recovers all those columns simultaneously. Dense for
3663 /// O(1) lookup in the per-op forward tangent.
3664 seeds: Vec<Vec<f64>>,
3665 /// Per-color decoding table: for each `(row, hess_idx)` entry,
3666 /// scatter `compressed_c[row] -> values[hess_idx]` after the
3667 /// per-color directional product.
3668 decoding: Vec<Vec<ColorWrite>>,
3669 /// For each objective tape: the distinct colors of vars it
3670 /// references. Lets us skip tape × color pairs where the tape
3671 /// has zero overlap with the color's seed.
3672 obj_tape_colors: Vec<Vec<u32>>,
3673 /// Same as `obj_tape_colors` but per constraint × summand.
3674 con_tape_colors: Vec<Vec<Vec<u32>>>,
3675 /// The same work, color-major, for the parallel flat Hessian path.
3676 hess_color_index: HessColorIndex,
3677 /// Offset of each row's run in the Jacobian `values` array, length
3678 /// `m + 1`. Lets a parallel row group take one contiguous slice.
3679 jac_row_off: Vec<u32>,
3680 /// Per-worker scratch for the parallel Jacobian, kept across evaluations:
3681 /// each holds a length-`n` gradient row, too big to allocate per call.
3682 #[cfg(not(target_arch = "wasm32"))]
3683 jac_scratch_pool: Vec<JacScratch>,
3684 /// Color of each variable's Hessian column, `u32::MAX` for a column
3685 /// that needs no pass of its own. Kept so
3686 /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3687 /// pass in `compressed`.
3688 var_color: Vec<u32>,
3689 /// Columns peeled out of the conflict structure and given singleton
3690 /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3691 peeled_cols: Vec<u32>,
3692 final_x: Option<Vec<Number>>,
3693 final_obj: Number,
3694 /// Converged constraint multipliers (length `m`, original `.nl` row
3695 /// order, user convention), captured from the same `finalize_solution`
3696 /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3697 /// block without re-deriving it from the algorithm's internal `y_c` /
3698 /// `y_d` split and scaling.
3699 final_lambda: Option<Vec<Number>>,
3700 /// Converged bound multipliers (length `n` each, Ipopt's internal
3701 /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3702 /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3703 /// reads for reduced costs.
3704 final_z_l: Option<Vec<Number>>,
3705 final_z_u: Option<Vec<Number>>,
3706 /// Per-row Jacobian accumulator (length n).
3707 scratch_row_grad: Vec<f64>,
3708 /// Scratch buffers for `Tape::hessian_directional` (each sized
3709 /// to `max_tape_n`).
3710 vals_scratch: Vec<f64>,
3711 dot_scratch: Vec<f64>,
3712 adj_scratch: Vec<f64>,
3713 adj_dot_scratch: Vec<f64>,
3714 /// Per-color compressed Hessian-vector results, sized to
3715 /// `prob.n`. Reused across `eval_h` calls but allocated once.
3716 compressed: Vec<Vec<f64>>,
3717 /// Per-direction "carries signal" mask for `hessian_vector_products`,
3718 /// kept here rather than allocated per call. This crate holds an
3719 /// explicit no-per-call-allocation line on the tape sweeps (see
3720 /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3721 /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3722 /// Krylov iteration.
3723 hvp_live: Vec<bool>,
3724 /// Model-derived scaling factors (gh #703), computed on demand by
3725 /// [`NlTnlp::enable_curvature_scaling`] and served through
3726 /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3727 /// the state every solve that does not select `curvature-based` stays
3728 /// in — computing them costs a pass over every stored Hessian entry
3729 /// and nothing else reads them.
3730 curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3731}
3732
3733// ---------------------------------------------------------------------
3734// Human-readable equation rendering (`print equation` in the debugger).
3735//
3736// Turns a parsed constraint back into infix text using the model's
3737// variable / constraint names, so the debugger can show the actual
3738// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3739// bare row index. This is the "print the specific equation, with
3740// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3741// argue makes equation-oriented model diagnostics actionable.
3742//
3743// The renderer is intentionally separate from the evaluation `Tape`:
3744// tapes are lossy for display (CSEs flattened, externals opaque),
3745// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3746// ---------------------------------------------------------------------
3747
3748/// Binding strength for parenthesization. Higher binds tighter.
3749const P_ADD: u8 = 10;
3750const P_MUL: u8 = 20;
3751const P_NEG: u8 = 30;
3752const P_POW: u8 = 40;
3753const P_ATOM: u8 = 100;
3754
3755/// Format a numeric literal compactly: integers without a trailing `.0`,
3756/// everything else via the shortest round-tripping `f64` form.
3757fn fmt_num(x: Number) -> String {
3758 if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3759 format!("{}", x as i64)
3760 } else {
3761 format!("{x}")
3762 }
3763}
3764
3765/// Display label for variable `i`: its `.col` name when present, else
3766/// `x[i]`.
3767fn var_label(i: usize, var_names: &[String]) -> String {
3768 match var_names.get(i) {
3769 Some(s) if !s.is_empty() => s.clone(),
3770 _ => format!("x[{i}]"),
3771 }
3772}
3773
3774/// Precedence of an expression's top operator (for child wrapping).
3775fn expr_prec(e: &Expr) -> u8 {
3776 match e {
3777 Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3778 Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3779 Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3780 Expr::Binary(BinOp::Pow, ..) => P_POW,
3781 Expr::Cse(inner) => expr_prec(inner),
3782 // Everything else renders as an atom / `f(...)` form.
3783 _ => P_ATOM,
3784 }
3785}
3786
3787/// Render an expression as infix text, using `var_names` for variable
3788/// labels where available (`x[i]` otherwise).
3789///
3790/// The debugger reaches the renderer through the constraint/objective
3791/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3792/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3793/// able to *see* the expression you just built is most of the debugging
3794/// story.
3795///
3796/// `Cse` bodies are inlined at every occurrence, so the output of a
3797/// heavily-shared DAG can be far larger than the DAG itself. Callers
3798/// rendering user-built expressions should bound the input first.
3799pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3800 render_expr(e, var_names, &[])
3801}
3802
3803/// Render `e`, wrapping in parentheses iff its precedence is looser than
3804/// `min_prec`.
3805fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
3806 let s = render_expr(e, vn, funcs);
3807 if expr_prec(e) < min_prec {
3808 format!("({s})")
3809 } else {
3810 s
3811 }
3812}
3813
3814fn unary_name(op: UnaryOp) -> &'static str {
3815 match op {
3816 UnaryOp::Neg => "-",
3817 UnaryOp::Sqrt => "sqrt",
3818 UnaryOp::Log => "log",
3819 UnaryOp::Exp => "exp",
3820 UnaryOp::Abs => "abs",
3821 UnaryOp::Sin => "sin",
3822 UnaryOp::Cos => "cos",
3823 UnaryOp::Log10 => "log10",
3824 UnaryOp::Tan => "tan",
3825 UnaryOp::Atan => "atan",
3826 UnaryOp::Acos => "acos",
3827 UnaryOp::Sinh => "sinh",
3828 UnaryOp::Cosh => "cosh",
3829 UnaryOp::Tanh => "tanh",
3830 UnaryOp::Asin => "asin",
3831 UnaryOp::Acosh => "acosh",
3832 UnaryOp::Asinh => "asinh",
3833 UnaryOp::Atanh => "atanh",
3834 UnaryOp::Erf => "erf",
3835 // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
3836 // the rendered text is read by humans debugging a model and must not
3837 // imply a sign the op does not have.
3838 UnaryOp::XLogX => "xlogx",
3839 }
3840}
3841
3842fn cmp_sym(op: CmpOp) -> &'static str {
3843 match op {
3844 CmpOp::Lt => "<",
3845 CmpOp::Le => "<=",
3846 CmpOp::Eq => "==",
3847 CmpOp::Ge => ">=",
3848 CmpOp::Gt => ">",
3849 CmpOp::Ne => "!=",
3850 }
3851}
3852
3853/// Append an additive sub-term with a tidy sign: a rendered term that
3854/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
3855/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
3856/// the term is itself a sum. The first term is emitted verbatim.
3857fn push_additive(out: &mut String, rendered: &str, first: bool) {
3858 if first {
3859 out.push_str(rendered);
3860 } else if let Some(rest) = rendered.strip_prefix('-') {
3861 out.push_str(" - ");
3862 out.push_str(rest);
3863 } else {
3864 out.push_str(" + ");
3865 out.push_str(rendered);
3866 }
3867}
3868
3869/// Render an [`Expr`] DAG to infix text using model names.
3870fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
3871 match e {
3872 Expr::Const(c) => fmt_num(*c),
3873 Expr::Var(i) => var_label(*i, vn),
3874 Expr::Binary(op, l, r) => match op {
3875 BinOp::Add => {
3876 let mut s = render_prec(l, P_ADD, vn, funcs);
3877 push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
3878 s
3879 }
3880 // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
3881 BinOp::Sub => format!(
3882 "{} - {}",
3883 render_prec(l, P_ADD, vn, funcs),
3884 render_prec(r, P_ADD + 1, vn, funcs)
3885 ),
3886 BinOp::Mul => format!(
3887 "{}*{}",
3888 render_prec(l, P_MUL, vn, funcs),
3889 render_prec(r, P_MUL, vn, funcs)
3890 ),
3891 BinOp::Div => format!(
3892 "{}/{}",
3893 render_prec(l, P_MUL, vn, funcs),
3894 render_prec(r, P_MUL + 1, vn, funcs)
3895 ),
3896 // Pow is right-associative: tighten the left operand instead.
3897 BinOp::Pow => format!(
3898 "{}^{}",
3899 render_prec(l, P_POW + 1, vn, funcs),
3900 render_prec(r, P_POW, vn, funcs)
3901 ),
3902 BinOp::Atan2 => format!(
3903 "atan2({}, {})",
3904 render_expr(l, vn, funcs),
3905 render_expr(r, vn, funcs)
3906 ),
3907 BinOp::CEntropy => format!(
3908 "centropy({}, {})",
3909 render_expr(l, vn, funcs),
3910 render_expr(r, vn, funcs)
3911 ),
3912 },
3913 Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
3914 Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
3915 Expr::Sum(xs) => {
3916 if xs.is_empty() {
3917 "0".to_string()
3918 } else {
3919 let mut s = String::new();
3920 for (k, x) in xs.iter().enumerate() {
3921 push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
3922 }
3923 s
3924 }
3925 }
3926 Expr::Cse(inner) => render_expr(inner, vn, funcs),
3927 Expr::Funcall { id, args } => {
3928 let name = funcs
3929 .iter()
3930 .find(|f| f.id == *id)
3931 .map(|f| f.name.clone())
3932 .unwrap_or_else(|| format!("extern#{id}"));
3933 let parts: Vec<String> = args
3934 .iter()
3935 .map(|a| match a {
3936 FuncallArg::Real(x) => render_expr(x, vn, funcs),
3937 FuncallArg::Str(s) => format!("{s:?}"),
3938 })
3939 .collect();
3940 format!("{name}({})", parts.join(", "))
3941 }
3942 Expr::Compare(op, a, b) => format!(
3943 "({} {} {})",
3944 render_expr(a, vn, funcs),
3945 cmp_sym(*op),
3946 render_expr(b, vn, funcs)
3947 ),
3948 Expr::And(a, b) => format!(
3949 "({} && {})",
3950 render_expr(a, vn, funcs),
3951 render_expr(b, vn, funcs)
3952 ),
3953 Expr::Or(a, b) => format!(
3954 "({} || {})",
3955 render_expr(a, vn, funcs),
3956 render_expr(b, vn, funcs)
3957 ),
3958 Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
3959 Expr::Cond { cond, then_, else_ } => format!(
3960 "if({}, {}, {})",
3961 render_expr(cond, vn, funcs),
3962 render_expr(then_, vn, funcs),
3963 render_expr(else_, vn, funcs)
3964 ),
3965 Expr::MinList(xs) => format!(
3966 "min({})",
3967 xs.iter()
3968 .map(|x| render_expr(x, vn, funcs))
3969 .collect::<Vec<_>>()
3970 .join(", ")
3971 ),
3972 Expr::MaxList(xs) => format!(
3973 "max({})",
3974 xs.iter()
3975 .map(|x| render_expr(x, vn, funcs))
3976 .collect::<Vec<_>>()
3977 .join(", ")
3978 ),
3979 }
3980}
3981
3982/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
3983/// `a + -2*b`). Returns `""` when there are no linear terms.
3984fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
3985 let mut out = String::new();
3986 // The `.nl` linear part carries an entry for every variable in the
3987 // row's Jacobian, including a 0 coefficient for variables that appear
3988 // only *nonlinearly* (they're rendered in the nonlinear part). Skip
3989 // those zeros so the equation reads as written, not as a sparsity map.
3990 let mut first = true;
3991 for (var, coef) in linear {
3992 if *coef == 0.0 {
3993 continue;
3994 }
3995 let neg = *coef < 0.0;
3996 let mag = coef.abs();
3997 let term = if mag == 1.0 {
3998 var_label(*var, vn)
3999 } else {
4000 format!("{}*{}", fmt_num(mag), var_label(*var, vn))
4001 };
4002 if first {
4003 if neg {
4004 out.push('-');
4005 }
4006 out.push_str(&term);
4007 first = false;
4008 } else {
4009 out.push_str(if neg { " - " } else { " + " });
4010 out.push_str(&term);
4011 }
4012 }
4013 out
4014}
4015
4016/// Render the constraint body (linear + nonlinear parts combined).
4017fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
4018 let mut s = render_linear(linear, &prob.var_names);
4019 let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
4020 if !nl_is_zero {
4021 let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
4022 if s.is_empty() {
4023 s = nl;
4024 } else {
4025 push_additive(&mut s, &nl, false);
4026 }
4027 }
4028 if s.is_empty() {
4029 s = "0".to_string();
4030 }
4031 s
4032}
4033
4034/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
4035/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
4036/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
4037pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
4038 // Diagnostic path: a recognized body has no tree, so this is one of
4039 // the places that pays to rebuild one. It runs once per rendered row.
4040 let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
4041 let lo = prob.g_l[k];
4042 let hi = prob.g_u[k];
4043 const INF: Number = 1.0e19;
4044 let has_lo = lo > -INF;
4045 let has_hi = hi < INF;
4046 match (has_lo, has_hi) {
4047 (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
4048 (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
4049 (true, false) => format!("{body} >= {}", fmt_num(lo)),
4050 (false, true) => format!("{body} <= {}", fmt_num(hi)),
4051 (false, false) => format!("{body} (free)"),
4052 }
4053}
4054
4055/// Render every constraint to text, index-aligned to `g` (original `.nl`
4056/// row order). Used to build the debugger's static equation book.
4057pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
4058 (0..prob.m)
4059 .map(|k| render_constraint_equation(prob, k))
4060 .collect()
4061}
4062
4063/// Structural sparsity of the constraint Jacobian as flat 0-based
4064/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
4065/// structurally depends on — the union of its linear support and the
4066/// `Var(i)` indices appearing anywhere in its nonlinear tree
4067/// ([`collect_vars`]). Sorted and deduplicated within each row.
4068///
4069/// This is the input to the debugger's Dulmage–Mendelsohn
4070/// structural-rank check (`diagnose`), which names the over-determined
4071/// (candidate redundant / inconsistent) equations and under-determined
4072/// variables. Naming the dependent rows — rather than reporting
4073/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
4074/// equation-oriented model debugging. See
4075/// <https://doi.org/10.69997/sct.147875>.
4076pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
4077 let mut irow: Vec<Index> = Vec::new();
4078 let mut jcol: Vec<Index> = Vec::new();
4079 let mut support: BTreeSet<usize> = BTreeSet::new();
4080 for k in 0..prob.m {
4081 support.clear();
4082 for &(j, _coef) in &prob.con_linear[k] {
4083 support.insert(j);
4084 }
4085 prob.con_nonlinear[k].collect_vars(&mut support);
4086 for &j in &support {
4087 irow.push(k as Index);
4088 jcol.push(j as Index);
4089 }
4090 }
4091 (irow, jcol)
4092}
4093
4094/// Flatten an additive expression tree into independent summand
4095/// expressions, each of which becomes its own Hessian tape.
4096///
4097/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
4098/// each summand tape once *per color the summand touches*, so the
4099/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
4100/// small (few variables → few colors) is what makes a sparse Hessian
4101/// cheap. A single fused tape spanning all `n` variables, by
4102/// contrast, is walked once per color → `O(n · tape_len)`, which on a
4103/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
4104/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
4105///
4106/// We therefore descend through the *affine* envelope of the sum, not
4107/// just `+`/`Sum`:
4108///
4109/// * `Neg(x)` → split `x`, negate each summand
4110/// * `Sub(l, r)` → split `l`; split `r`, negate each summand
4111/// * `c * x` / `x * c` → split `x`, scale each summand by `c`
4112/// * `x / c` → split `x`, scale each summand by `1/c`
4113///
4114/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
4115/// least-squares / max-entropy shapes) still decomposes to its leaf
4116/// terms instead of collapsing into one giant tape. The carried
4117/// `factor` is materialised onto each leaf only when it differs from
4118/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
4119/// is unchanged and the per-summand op count grows by at most one.
4120fn split_top_sums(expr: &Expr) -> Vec<Expr> {
4121 let mut out = Vec::new();
4122 fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4123 if factor == 1.0 {
4124 out.push(e.clone());
4125 } else if factor == -1.0 {
4126 out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
4127 } else {
4128 out.push(Expr::Binary(
4129 BinOp::Mul,
4130 Box::new(Expr::Const(factor)),
4131 Box::new(e.clone()),
4132 ));
4133 }
4134 }
4135 fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
4136 match e {
4137 Expr::Sum(terms) => {
4138 for t in terms {
4139 go(t, factor, out);
4140 }
4141 }
4142 Expr::Binary(BinOp::Add, l, r) => {
4143 go(l, factor, out);
4144 go(r, factor, out);
4145 }
4146 Expr::Binary(BinOp::Sub, l, r) => {
4147 go(l, factor, out);
4148 go(r, -factor, out);
4149 }
4150 Expr::Unary(UnaryOp::Neg, x) => {
4151 go(x, -factor, out);
4152 }
4153 // Affine scaling: distribute a constant coefficient into
4154 // the summands so a leading `c·(Σ …)` still splits.
4155 Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
4156 (Expr::Const(c), _) => go(r, factor * c, out),
4157 (_, Expr::Const(c)) => go(l, factor * c, out),
4158 _ => push_leaf(e, factor, out),
4159 },
4160 Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
4161 Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
4162 _ => push_leaf(e, factor, out),
4163 },
4164 _ => push_leaf(e, factor, out),
4165 }
4166 }
4167 go(expr, 1.0, &mut out);
4168 if out.is_empty() {
4169 out.push(Expr::Const(0.0));
4170 }
4171 out
4172}
4173
4174/// Greedy column coloring of a symmetric sparsity pattern stored
4175/// as lower-triangle pairs.
4176///
4177/// Builds the column-intersection graph: columns `c1` and `c2` are
4178/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
4179/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
4180/// satisfies the direct-recovery condition for symmetric Hessians
4181/// (Coleman-Moré): for any color, the columns it contains have
4182/// pairwise disjoint row supports, so a single H·s product
4183/// recovers them all unambiguously.
4184///
4185/// Returns `(var_color, n_colors)` where `var_color[k]` is the
4186/// color assigned to variable `k`, or `u32::MAX` for variables
4187/// not in any Hessian pair (they contribute nothing and don't
4188/// need a color).
4189/// A column is treated as **dense** — and peeled out of the coloring —
4190/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
4191/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
4192/// factor keeps uniformly-dense Hessians (where every column looks like
4193/// every other) on the plain coloring path, and the absolute floor stops
4194/// a very sparse average from declaring a 10-entry column "dense".
4195const DENSE_COL_FACTOR: usize = 16;
4196const DENSE_COL_MIN: usize = 32;
4197/// Largest relative error a peeled column may inflict on the smallest
4198/// entry recovered from its pass before
4199/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
4200///
4201/// The ratio is a worst-case bound — the pass's roundoff floor over the
4202/// smallest entry read out of it — and it is a *loose* one, by an amount
4203/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
4204/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
4205/// measures 4e-16. The cut is therefore calibrated on the corpus, not
4206/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
4207/// models that peel anything, the highest bound on a column that recovers
4208/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
4209/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
4210/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
4211///
4212/// 1e-8 sits below both, which deliberately buys correctness with speed:
4213/// the errors are asymmetric, since a false veto costs one model a coloring
4214/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
4215/// while a missed veto costs a solve its certificate. Five of the 56 take a
4216/// veto they do not need; none takes a wrong answer. Raising this constant
4217/// to buy those five back would put the cut inside a 2.8x window measured
4218/// on two model families, which is not a margin worth trading a certificate
4219/// for.
4220const PEEL_MAX_REL_ERR: f64 = 1e-8;
4221/// Hard cap on how many columns get peeled, applied on top of the
4222/// pay-for-itself rule in [`select_peeled_cols`].
4223const MAX_PEELED_COLS: usize = 256;
4224
4225/// Lower bound on the color count that results from peeling `peeled`.
4226///
4227/// Peeling costs one color per peeled column. On what remains, any row
4228/// with `d` surviving entries makes those `d` columns pairwise
4229/// conflicting, so the greedy walk needs at least `d` colors. Hence
4230/// `|peeled| + max surviving row degree` is a lower bound on the total,
4231/// computable in one O(nnz) pass — no coloring required.
4232///
4233/// The bound is what makes the choice decidable at all: the plain
4234/// coloring cannot be run as a comparison baseline, because on the
4235/// one-dense-row case the plain walk is itself O(n^2) — precisely the
4236/// blowup peeling exists to avoid.
4237fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
4238 let mut deg = vec![0usize; n];
4239 for &(i, j) in lower_pairs {
4240 if peeled[i] || peeled[j] {
4241 continue;
4242 }
4243 deg[j] += 1;
4244 if i != j {
4245 deg[i] += 1;
4246 }
4247 }
4248 let n_peeled = peeled.iter().filter(|&&p| p).count();
4249 n_peeled + deg.iter().copied().max().unwrap_or(0)
4250}
4251
4252/// Choose which of the candidate dense columns to actually peel.
4253///
4254/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
4255/// top `k` candidates by degree, over a doubling ladder of `k` up to
4256/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
4257/// so peeling has to earn its colors.
4258///
4259/// **Why not just truncate an over-long candidate list.** Cutting the
4260/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
4261/// columns that miss the cut stay in the conflict structure, so the base
4262/// color count is untouched and the singleton colors are pure addition.
4263/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
4264/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
4265/// regression in exactly the quantity peeling exists to reduce. The
4266/// bound above sees it: peeling `k` of several thousand equal-degree
4267/// columns leaves the surviving max degree unchanged, so every `k > 0`
4268/// scores strictly worse than peeling nothing.
4269///
4270/// **Why not a simple degree rule.** "Peel only columns denser than some
4271/// fraction of `n`" would refuse three rows of degree 10,000 in a
4272/// 200,000-variable model, where peeling three columns takes the
4273/// coloring from >= 10,000 down to a handful. The win depends on what
4274/// peeling leaves behind, not on the peeled column's degree alone.
4275fn select_peeled_cols(
4276 n: usize,
4277 lower_pairs: &[(usize, usize)],
4278 deg: &[usize],
4279 mut candidates: Vec<usize>,
4280) -> Vec<usize> {
4281 if candidates.is_empty() {
4282 return candidates;
4283 }
4284 // Worst offenders first; they remove the most conflict per color spent.
4285 candidates.sort_unstable_by(|&a, &b| deg[b].cmp(°[a]).then(a.cmp(&b)));
4286 candidates.truncate(MAX_PEELED_COLS);
4287
4288 let mut mask = vec![false; n];
4289 let mut best_k = 0usize;
4290 // Peeling nothing: the bound is just the largest row degree.
4291 let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
4292
4293 // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
4294 // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
4295 // mask only ever gains entries, so each step just marks the new slice.
4296 let mut marked = 0usize;
4297 let mut k = 1usize;
4298 loop {
4299 let k_now = k.min(candidates.len());
4300 for &j in &candidates[marked..k_now] {
4301 mask[j] = true;
4302 }
4303 marked = k_now;
4304 let bound = peel_color_bound(n, lower_pairs, &mask);
4305 if bound < best_bound {
4306 best_bound = bound;
4307 best_k = k_now;
4308 }
4309 if k_now == candidates.len() {
4310 break;
4311 }
4312 k *= 2;
4313 }
4314
4315 candidates.truncate(best_k);
4316 candidates
4317}
4318
4319/// Greedy distance-1 coloring of the Hessian's column-intersection
4320/// graph, with **dense columns peeled out**.
4321///
4322/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
4323/// marks a column that needs no directional product of its own: either
4324/// it has no Hessian entries at all, or every entry it has is recovered
4325/// from a peeled column's pass (see below).
4326///
4327/// # Why peeling
4328///
4329/// The plain coloring rule is "two columns may share a color when they
4330/// have no common nonzero row". A single **dense row** — one variable
4331/// multiplying a sum over all the others, a total-cost variable, a
4332/// shared design parameter — puts a nonzero in *every* column at that
4333/// row, so every pair of columns conflicts and the greedy walk hands out
4334/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
4335/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
4336/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
4337/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
4338///
4339/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
4340/// own singleton color: one directional product with seed `e_d` recovers
4341/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
4342/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
4343/// at row `j` of that same pass. So row `d` no longer constrains any
4344/// other column's color and is dropped from the conflict structure, and
4345/// the remaining columns colour on their genuine sparsity. On the
4346/// one-dense-row case above this takes `n_colors` from `n` to a handful.
4347///
4348/// # What peeling costs, and `peel_veto`
4349///
4350/// Recovering `H[d, j]` from column `d`'s pass is exact in real
4351/// arithmetic but not in floating point: the pass is accumulated at the
4352/// scale of the whole dense column, so every entry read out of it carries
4353/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
4354/// ordinary path — column `j`'s own pass — would have left a floor of
4355/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
4356/// dense column is well scaled, and they do on every peel-firing model in
4357/// the benchmark corpus but one. Where a peeled column spans a wide
4358/// dynamic range, though, that floor swamps its small entries: a column
4359/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4360///
4361/// Structure cannot see this — it is a property of the values — so
4362/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4363/// and passes the offenders back here in `peel_veto`, which bars them
4364/// from being peeled again. A vetoed column is colored normally, its row
4365/// returns to the conflict structure, and its entries go back to the
4366/// accurate path.
4367fn greedy_hessian_coloring(
4368 n: usize,
4369 lower_pairs: &[(usize, usize)],
4370 peel_veto: &[bool],
4371) -> (Vec<u32>, usize, Vec<bool>) {
4372 if n == 0 {
4373 return (Vec::new(), 0, Vec::new());
4374 }
4375
4376 // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4377 // i >= j contributes row i to column j and row j to column i; a
4378 // diagonal contributes once.
4379 let mut deg = vec![0usize; n];
4380 for &(i, j) in lower_pairs {
4381 deg[j] += 1;
4382 if i != j {
4383 deg[i] += 1;
4384 }
4385 }
4386
4387 // Pick the dense columns to peel.
4388 let total: usize = deg.iter().sum();
4389 let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4390 let mut peeled = vec![false; n];
4391 let candidates: Vec<usize> = (0..n)
4392 .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4393 .collect();
4394 let dense = select_peeled_cols(n, lower_pairs, °, candidates);
4395 for &j in &dense {
4396 peeled[j] = true;
4397 }
4398
4399 // Conflict structure over the *non-peeled* columns only. Pairs with
4400 // a peeled endpoint are recovered from that endpoint's own pass, so
4401 // they neither need a color nor constrain one.
4402 let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4403 let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4404 for &(i, j) in lower_pairs {
4405 if peeled[i] || peeled[j] {
4406 continue;
4407 }
4408 col_rows[j].push(i as u32);
4409 row_cols[i].push(j as u32);
4410 if i != j {
4411 col_rows[i].push(j as u32);
4412 row_cols[j].push(i as u32);
4413 }
4414 }
4415
4416 let mut var_color = vec![u32::MAX; n];
4417 let mut forbidden = vec![u32::MAX; n + 1];
4418 let mut n_colors: u32 = 0;
4419
4420 for j in 0..n {
4421 // Peeled columns are colored below; a column with no surviving
4422 // Hessian entries needs no color at all.
4423 if peeled[j] || col_rows[j].is_empty() {
4424 continue;
4425 }
4426 // Mark colors used by any column sharing a row with `j`.
4427 // Row-of-col -> col-in-row visit pattern collects all
4428 // distance-1 neighbors in the column-intersection graph.
4429 for &r in &col_rows[j] {
4430 for &c in &row_cols[r as usize] {
4431 if c as usize == j {
4432 continue;
4433 }
4434 let cc = var_color[c as usize];
4435 if cc != u32::MAX {
4436 forbidden[cc as usize] = j as u32;
4437 }
4438 }
4439 }
4440 // First color not stamped with `j as u32`.
4441 let mut chosen: u32 = 0;
4442 while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4443 chosen += 1;
4444 }
4445 var_color[j] = chosen;
4446 if chosen + 1 > n_colors {
4447 n_colors = chosen + 1;
4448 }
4449 }
4450
4451 // One singleton color per peeled column, appended after the shared
4452 // ones so the non-peeled numbering is untouched.
4453 for &j in &dense {
4454 var_color[j] = n_colors;
4455 n_colors += 1;
4456 }
4457
4458 (var_color, n_colors as usize, peeled)
4459}
4460
4461/// Everything downstream of the Hessian coloring: seed vectors, the
4462/// per-color decode table, the per-tape color sets, and the shared-CSE
4463/// per-color summand / prelude-reach tables.
4464///
4465/// Split out of [`NlTnlp::new`] because
4466/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4467/// time, with a peel veto in hand, once it has seen real Hessian values.
4468fn build_color_tables(
4469 n: usize,
4470 m: usize,
4471 lower_pairs: &[(usize, usize)],
4472 tape_mask: &[bool],
4473 peel_veto: &[bool],
4474 obj_tapes: &[Tape],
4475 con_tapes: &[Vec<Tape>],
4476 con_hybrid: Option<&mut ConHybrid>,
4477) -> ColorTables {
4478 // Hessian column coloring. The chromatic number of the
4479 // column-intersection graph bounds how many directional
4480 // Hessian-vector products we need per `eval_h` call —
4481 // typically O(stencil) for PDE-mesh problems.
4482 //
4483 // Only the entries a *tape* can write take part. `tape_mask` is empty
4484 // when every entry is a tape entry (no quadratic structure), which is
4485 // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4486 // entries are excluded, since they are scattered from their stored
4487 // values and never read out of a directional product. A variable that
4488 // appears only in quadratic rows then has no color at all
4489 // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4490 // column with no surviving entries.
4491 let colored_pairs: Vec<(usize, usize)>;
4492 let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4493 lower_pairs
4494 } else {
4495 colored_pairs = lower_pairs
4496 .iter()
4497 .zip(tape_mask)
4498 .filter_map(|(p, &t)| t.then_some(*p))
4499 .collect();
4500 &colored_pairs
4501 };
4502 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4503
4504 // Per-color seed vectors (dense for O(1) Var lookup in
4505 // `Tape::hessian_directional`).
4506 let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4507 for (k, &c) in var_color.iter().enumerate() {
4508 if c != u32::MAX {
4509 seeds[c as usize][k] = 1.0;
4510 }
4511 }
4512
4513 // Per-color decoding table. For each lower-tri pair (i, j)
4514 // with i >= j, the entry belongs to column j's color: after
4515 // computing compressed_{c_j} = (H · s_{c_j}), the value at
4516 // row i is exactly H[i, j] (coloring guarantees no other
4517 // column in c_j has a nonzero at row i).
4518 // Built straight from `lower_pairs`, which is sorted, so each
4519 // color's table is in ascending `hess_idx` order and the decode
4520 // scatter walks `values` forward instead of hopping (the old
4521 // build drained a `HashMap`, whose iteration order is arbitrary).
4522 let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4523 for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4524 // An entry no tape writes has no pass to be decoded out of; the
4525 // quadratic scatter already put its value there.
4526 if !tape_mask.is_empty() && !tape_mask[idx] {
4527 continue;
4528 }
4529 // Which directional product recovers H[i, j]? Column `j`'s,
4530 // read at row `i` — except when `i` is a peeled column and
4531 // `j` is not: then `j` may have no color of its own, and the
4532 // entry is already in column `i`'s pass at row `j`, since
4533 // H[i, j] == H[j, i].
4534 let (c, row) = if peeled[i] && !peeled[j] {
4535 (var_color[i], j)
4536 } else {
4537 (var_color[j], i)
4538 };
4539 debug_assert!(
4540 c != u32::MAX,
4541 "Hessian pair ({i}, {j}) at index {idx} has no color"
4542 );
4543 decoding[c as usize].push(ColorWrite {
4544 row: row as u32,
4545 hess_idx: idx as u32,
4546 });
4547 }
4548
4549 // Per-tape distinct color set: for each tape, the colors
4550 // its variables fall into. `eval_h` loops over only these
4551 // (tape, color) pairs instead of n_tapes × n_colors.
4552 let tape_colors = |t: &Tape| -> Vec<u32> {
4553 let mut s: Vec<u32> = t
4554 .variables()
4555 .into_iter()
4556 .map(|v| var_color[v])
4557 .filter(|&c| c != u32::MAX)
4558 .collect();
4559 s.sort_unstable();
4560 s.dedup();
4561 s
4562 };
4563 let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4564 let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4565 .iter()
4566 .map(|row| row.iter().map(tape_colors).collect())
4567 .collect();
4568
4569 // Shared-CSE Hessian tables (issue #557): the per-color summand
4570 // lists, row lookup, and packed forward-value arena `eval_h`'s
4571 // hybrid path walks. Built whenever the hybrid tape is — not just
4572 // above the gate — so flipping `use_for_hess` on (tests, the
4573 // force env var) needs no extra setup; the cost is one f64 per
4574 // local op plus small index tables.
4575 if let Some(h) = con_hybrid {
4576 let n_sum = h.tape.n_summands();
4577 let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4578 let mut acc = 0usize;
4579 for s in &h.tape.summands {
4580 local_off.push(acc);
4581 acc += s.ops.len();
4582 }
4583 local_off.push(acc);
4584 h.local_vals_all = vec![0.0; acc];
4585 h.local_off = local_off;
4586
4587 let mut summand_row = vec![0u32; n_sum];
4588 for i in 0..m {
4589 for si in h.row_start[i]..h.row_start[i + 1] {
4590 summand_row[si] = i as u32;
4591 }
4592 }
4593 h.summand_row = summand_row;
4594
4595 // A summand's variable set (`all_vars`) equals its flat tape's,
4596 // so this is `con_tape_colors` inverted to color-major order —
4597 // the loop `eval_h` actually runs.
4598 let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4599 for (si, s) in h.tape.summands.iter().enumerate() {
4600 let mut cs: Vec<u32> = s
4601 .all_vars
4602 .iter()
4603 .map(|&v| var_color[v])
4604 .filter(|&c| c != u32::MAX)
4605 .collect();
4606 cs.sort_unstable();
4607 cs.dedup();
4608 for c in cs {
4609 by_color[c as usize].push(si as u32);
4610 }
4611 }
4612 // Per-color prelude reach: the union of `prelude_reach` over the
4613 // color's summands, ascending. A union of operand-closed
4614 // ascending sets is itself operand-closed and ascending, which is
4615 // exactly what the two prelude sweeps require. Deduped with an
4616 // epoch-tagged buffer so the build costs
4617 // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4618 // it saves — rather than `n_colors × |prelude|`.
4619 let np = h.tape.n_prelude_ops();
4620 let mut seen: Vec<u32> = vec![0; np];
4621 let mut epoch: u32 = 0;
4622 let mut reach: Vec<u32> = Vec::new();
4623 let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4624 for list in &by_color {
4625 reach_off.push(reach.len());
4626 epoch += 1;
4627 let start = reach.len();
4628 for &si in list {
4629 for &p in &h.tape.summands[si as usize].prelude_reach {
4630 if seen[p] != epoch {
4631 seen[p] = epoch;
4632 reach.push(p as u32);
4633 }
4634 }
4635 }
4636 reach[start..].sort_unstable();
4637 }
4638 reach_off.push(reach.len());
4639 h.hess_color_reach = reach;
4640 h.hess_color_reach_off = reach_off;
4641
4642 h.hess_color_summands = by_color;
4643 h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4644 h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4645 h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4646 h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4647 h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4648 }
4649
4650 ColorTables {
4651 var_color,
4652 n_colors,
4653 peeled_cols: peeled
4654 .iter()
4655 .enumerate()
4656 .filter(|(_, p)| **p)
4657 .map(|(j, _)| j as u32)
4658 .collect(),
4659 seeds,
4660 decoding,
4661 hess_color_index: HessColorIndex::build(n_colors, &con_tape_colors),
4662 obj_tape_colors,
4663 con_tape_colors,
4664 }
4665}
4666
4667/// The color-dependent half of an [`NlTnlp`], as built by
4668/// [`build_color_tables`].
4669struct ColorTables {
4670 var_color: Vec<u32>,
4671 n_colors: usize,
4672 /// Columns given a singleton color and dropped from the conflict
4673 /// structure. Small by construction (`MAX_PEELED_COLS`).
4674 peeled_cols: Vec<u32>,
4675 seeds: Vec<Vec<f64>>,
4676 decoding: Vec<Vec<ColorWrite>>,
4677 obj_tape_colors: Vec<Vec<u32>>,
4678 con_tape_colors: Vec<Vec<Vec<u32>>>,
4679 /// `con_tape_colors` inverted to color-major, CSR-packed: the (row, tape)
4680 /// pairs whose tape touches each color, and the objective tapes likewise.
4681 ///
4682 /// The flat `eval_h` walks tapes and, inside each, the colors it touches,
4683 /// so several rows accumulate into one `compressed[c]` — fine serially,
4684 /// unshareable across threads. Walking color-major makes each color's
4685 /// accumulator private to its task, at the cost of forwarding a tape once
4686 /// per color it touches instead of once (measured at 2.9 colors per tape
4687 /// on an AC-SCOPF model, against a directional sweep that costs several
4688 /// forwards, so ~20% more serial work for 28-way parallelism).
4689 ///
4690 /// Within a color the pairs stay in ascending (row, tape) order, which is
4691 /// the order the tape-major loop contributes them in, so the two walks
4692 /// accumulate identical sums in identical order — bit-for-bit, not merely
4693 /// within tolerance. `parallel_matches_serial_bit_for_bit` pins that.
4694 hess_color_index: HessColorIndex,
4695}
4696
4697/// Color-major (row, tape) work lists for the flat Hessian path. See
4698/// [`ColorTables::hess_color_index`].
4699#[derive(Debug, Default, Clone)]
4700pub(crate) struct HessColorIndex {
4701 /// Constraint (row, tape) pairs per color, CSR. The objective's tapes are
4702 /// deliberately absent: `eval_h` accumulates them into `compressed`
4703 /// *before* the constraint block and keeps doing so serially, which is
4704 /// what makes the parallel walk's per-color ordering identical to the
4705 /// serial one — objective first, then rows ascending.
4706 con_off: Vec<u32>,
4707 con_row: Vec<u32>,
4708 con_tape: Vec<u32>,
4709}
4710
4711impl HessColorIndex {
4712 fn build(n_colors: usize, con_tape_colors: &[Vec<Vec<u32>>]) -> Self {
4713 // Rows ascending, then tapes ascending: the tape-major loop's order.
4714 let mut con: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_colors];
4715 for (k, row) in con_tape_colors.iter().enumerate() {
4716 for (ti, colors) in row.iter().enumerate() {
4717 for &c in colors {
4718 con[c as usize].push((k as u32, ti as u32));
4719 }
4720 }
4721 }
4722 let mut out = HessColorIndex {
4723 con_off: Vec::with_capacity(n_colors + 1),
4724 ..Default::default()
4725 };
4726 out.con_off.push(0);
4727 for list in &con {
4728 for &(k, ti) in list {
4729 out.con_row.push(k);
4730 out.con_tape.push(ti);
4731 }
4732 out.con_off.push(out.con_row.len() as u32);
4733 }
4734 out
4735 }
4736
4737 /// The (row, tape) pairs of color `c`, as two parallel slices.
4738 fn con_pairs(&self, c: usize) -> (&[u32], &[u32]) {
4739 let (a, b) = (self.con_off[c] as usize, self.con_off[c + 1] as usize);
4740 (&self.con_row[a..b], &self.con_tape[a..b])
4741 }
4742}
4743
4744impl NlTnlp {
4745 /// Build the TNLP, panicking if AMPL external-function resolution fails.
4746 ///
4747 /// Kept for the many infallible call sites (CLI, tests) that operate on
4748 /// `.nl` models known to need no external libraries. Surfaces that can be
4749 /// handed an arbitrary user model — notably the Python `read_nl` binding —
4750 /// must call [`Self::try_new`] instead so a missing `$AMPLFUNC` library
4751 /// becomes a catchable error rather than an uncatchable panic across the
4752 /// pyo3 boundary.
4753 pub fn new(prob: NlProblem) -> Self {
4754 Self::try_new(prob)
4755 .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4756 }
4757
4758 /// Build the TNLP, returning an error (instead of panicking) when AMPL
4759 /// imported functions named by the model can't be resolved — e.g.
4760 /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4761 /// referenced function id isn't registered by any loaded library.
4762 pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4763 // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4764 // objective — the A/B reference for the constant-structure path,
4765 // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4766 // fast path's derivatives are checked against the ones they replace
4767 // on a real model, and how a suspected fast-path bug is bisected
4768 // against a reference that computes the same numbers a different way.
4769 Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4770 }
4771
4772 /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4773 /// explicitly on or off.
4774 ///
4775 /// The env var `try_new` reads is process-global, which is exactly wrong
4776 /// for the differential test that has to build the *same* model both ways
4777 /// and compare the derivatives — so the knob is a parameter here and the
4778 /// env var only chooses its default.
4779 pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4780 // Resolve any AMPL imported (external) functions. Walk every
4781 // nonlinear expression to collect the funcall ids actually
4782 // referenced; load the libraries named in $AMPLFUNC and bind
4783 // each id to its (library, registered-name) pair so the tape
4784 // builder can emit live `TapeOp::Funcall` ops.
4785 let mut referenced: BTreeSet<usize> = BTreeSet::new();
4786 // Only trees can carry a `Funcall`: the recognizer refuses one, so
4787 // a recognized body provably contains none.
4788 for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4789 if let Some(e) = body.tree() {
4790 super::nl_external::collect_funcall_ids(e, &mut referenced);
4791 }
4792 }
4793 let resolver = if referenced.is_empty() {
4794 super::nl_external::ExternalResolver::default()
4795 } else {
4796 super::nl_external::ExternalResolver::build_for_problem(
4797 &prob.imported_funcs,
4798 &referenced,
4799 )?
4800 };
4801
4802 // Recognize the degree-≤2 objective and rows *before* anything is
4803 // taped, because the win is not in evaluating the tape faster — it
4804 // is in never building it. On `qcqp500-3c` the ten quadratic rows
4805 // are 2.32 M monomials, one `Tape` each; recognizing them first
4806 // means those 2.32 M tapes, their color lists and their per-tape
4807 // sparsity sets are never allocated.
4808 //
4809 // A row that is trivially zero is left alone: it has no nonlinear
4810 // part to replace, and routing it through a (necessarily empty)
4811 // quadratic form would touch every model in the corpus to save
4812 // nothing.
4813 let mut quad = QuadraticStructure::new(prob.m);
4814 if use_quadratic {
4815 // `is_expanded_quadratic` is the accuracy gate, not an
4816 // optimization: it admits only forms whose read-out repeats the
4817 // additions the `.nl` writer already wrote. See its docs — and
4818 // note it is checked *before* recognition, so a factored form
4819 // costs one cheap structural walk rather than a full expansion.
4820 // Recognition is the parser's job now (gh #588, Q5) for the
4821 // bodies it could reach; `admitted_quad_form` reads its answer
4822 // back, and still walks a tree for the bodies that kept one —
4823 // a `from_expressions` model, a factored row rewound by the
4824 // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
4825 //
4826 // A form that gate refuses is offered the *factored* read-out
4827 // before it falls back to a tape (gh #673): the gate is a
4828 // verdict on the expansion, not on the body, and a sum of
4829 // squared residuals keeps its structure by keeping its squares.
4830 // `push_body_form` owns that order.
4831 if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
4832 quad.assign_objective(f);
4833 }
4834 for k in 0..prob.m {
4835 if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
4836 quad.assign_row(k, f);
4837 }
4838 }
4839 }
4840
4841 // Flatten objective and each constraint into independent
4842 // summands. Each summand becomes its own `Tape` (CSE bodies
4843 // are deduplicated within a tape via Rc identity in
4844 // `Tape::build`; bodies shared across summands are
4845 // duplicated, which we accept as a simplicity tradeoff).
4846 let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
4847 Vec::new()
4848 } else {
4849 split_top_sums(&prob.obj_expr())
4850 .iter()
4851 .map(|e| Tape::build_with_externals(e, &resolver))
4852 .collect()
4853 };
4854
4855 let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
4856 let mut con_roots: Vec<Expr> = Vec::new();
4857 let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
4858 for k in 0..prob.m {
4859 row_start.push(con_roots.len());
4860 // A row with a quadratic form contributes no tape and no
4861 // hybrid-tape root, so `con_tapes[k]` is empty and its
4862 // `row_start` range is empty too.
4863 if quad.row_form(k).is_some() {
4864 con_tapes.push(Vec::new());
4865 continue;
4866 }
4867 let summands = split_top_sums(&prob.con_expr(k));
4868 con_tapes.push(
4869 summands
4870 .iter()
4871 .map(|e| Tape::build_with_externals(e, &resolver))
4872 .collect(),
4873 );
4874 // Move (not clone) the split summands into the root list: their
4875 // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
4876 // sharing on `Arc` pointer identity, so the roots must be the
4877 // same allocations the parse produced.
4878 con_roots.extend(summands);
4879 }
4880 row_start.push(con_roots.len());
4881
4882 // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
4883 // building only when some CSE body is actually referenced from two
4884 // or more summands — otherwise the prelude comes out empty and the
4885 // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
4886 // gates the opcodes `build_multi` would panic on.
4887 // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
4888 // whole constraint block. Diagnostic only: it is how the
4889 // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
4890 // on a real model, and how a suspected hybrid-path bug is bisected
4891 // against a reference that computes the same derivatives a
4892 // different way.
4893 let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
4894 None
4895 } else if hybrid_supported(&con_roots) {
4896 let tape = HybridTape::build_multi(&con_roots);
4897 (tape.n_prelude_ops() > 0).then(|| {
4898 let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
4899 let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
4900 // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
4901 // regardless of the op ratio. Diagnostic only — it is how the
4902 // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
4903 // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
4904 // models that sit below the gate.
4905 let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
4906 ConHybrid {
4907 prelude_vals: vec![0.0; tape.n_prelude_ops()],
4908 local_vals: vec![0.0; tape.max_summand_ops()],
4909 local_adj: vec![0.0; tape.max_summand_ops()],
4910 prelude_adj: vec![0.0; tape.n_prelude_ops()],
4911 use_for_jac: flat_ops as f64
4912 >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
4913 use_for_hess: force_hess
4914 || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
4915 local_vals_all: Vec::new(),
4916 local_off: Vec::new(),
4917 summand_row: Vec::new(),
4918 hess_color_summands: Vec::new(),
4919 hess_color_reach: Vec::new(),
4920 hess_color_reach_off: Vec::new(),
4921 prelude_dot: Vec::new(),
4922 hess_prelude_adj: Vec::new(),
4923 prelude_adj_dot: Vec::new(),
4924 local_dot: Vec::new(),
4925 local_adj_dot: Vec::new(),
4926 row_start,
4927 tape,
4928 }
4929 })
4930 } else {
4931 None
4932 };
4933 drop(con_roots);
4934
4935 // Hessian-of-Lagrangian sparsity: union of each tape's own
4936 // structural Hessian sparsity.
4937 // One flat `Vec`, sorted and deduped once, rather than a global
4938 // `BTreeSet` fed a single insert at a time across every summand
4939 // in the model: sort+dedup walks contiguous memory where the tree
4940 // chased a pointer and allocated a node per entry. The result is
4941 // exactly the ascending order the rest of this function wants, so
4942 // it doubles as `lower_pairs` instead of being copied into it.
4943 let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
4944 for t in &obj_tapes {
4945 tape_pairs.extend(t.hessian_sparsity());
4946 }
4947 for row in &con_tapes {
4948 for t in row {
4949 tape_pairs.extend(t.hessian_sparsity());
4950 }
4951 }
4952 tape_pairs.sort_unstable();
4953 tape_pairs.dedup();
4954
4955 // The assembled pattern is the tapes' union *plus* the quadratic
4956 // forms'. They are kept apart because the coloring only ever needs
4957 // the tape half — a quadratic block's entries are scattered
4958 // directly, not recovered from a directional product — and coloring
4959 // a dense quadratic block would put the color count back at `n` to
4960 // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
4961 let mut lower_pairs = tape_pairs.clone();
4962 if !quad.is_empty() {
4963 for f in quad
4964 .objective_form()
4965 .into_iter()
4966 .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
4967 {
4968 lower_pairs.extend(
4969 quad.lower_triangle(f)
4970 .map(|(r, c, _)| (r as usize, c as usize)),
4971 );
4972 }
4973 lower_pairs.sort_unstable();
4974 lower_pairs.dedup();
4975 }
4976
4977 // Which assembled entries a tape can write. Both sides are sorted
4978 // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
4979 // mask is left empty (meaning "all of them") when nothing was
4980 // recognized.
4981 let h_tape_mask: Vec<bool> = if quad.is_empty() {
4982 Vec::new()
4983 } else {
4984 let mut mask = vec![false; lower_pairs.len()];
4985 let mut t = 0usize;
4986 for (idx, pair) in lower_pairs.iter().enumerate() {
4987 if t < tape_pairs.len() && tape_pairs[t] == *pair {
4988 mask[idx] = true;
4989 t += 1;
4990 }
4991 }
4992 debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
4993 mask
4994 };
4995 drop(tape_pairs);
4996
4997 let mut h_irow = Vec::with_capacity(lower_pairs.len());
4998 let mut h_jcol = Vec::with_capacity(lower_pairs.len());
4999 for &(hi, lo) in &lower_pairs {
5000 h_irow.push(hi as i32);
5001 h_jcol.push(lo as i32);
5002 }
5003
5004 // Bind each form's lower-triangle entries to their index in the
5005 // assembled pattern. `lower_pairs` is sorted, so the lookup is a
5006 // binary search — done once here, never again on the hot path.
5007 if !quad.is_empty() {
5008 quad.bind_slots(|r, c| {
5009 lower_pairs
5010 .binary_search(&(r as usize, c as usize))
5011 .unwrap_or_else(|_| {
5012 unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
5013 })
5014 });
5015 }
5016
5017 // Hessian column coloring and everything keyed off it. The
5018 // chromatic number of the column-intersection graph bounds how
5019 // many directional Hessian-vector products we need per `eval_h`
5020 // call — typically O(stencil) for PDE-mesh problems.
5021 let ColorTables {
5022 var_color,
5023 n_colors,
5024 peeled_cols,
5025 seeds,
5026 decoding,
5027 obj_tape_colors,
5028 con_tape_colors,
5029 hess_color_index,
5030 } = build_color_tables(
5031 prob.n,
5032 prob.m,
5033 &lower_pairs,
5034 &h_tape_mask,
5035 &vec![false; prob.n],
5036 &obj_tapes,
5037 &con_tapes,
5038 con_hybrid.as_mut(),
5039 );
5040
5041 // Per-row Jacobian sparsity = union of tape vars plus
5042 // linear-segment vars.
5043 let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
5044 let mut jac_nnz = 0;
5045 for (i, row_tapes) in con_tapes.iter().enumerate() {
5046 let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
5047 for t in row_tapes {
5048 cols.extend(t.variables());
5049 }
5050 // A quadratic row has no tape, so its Jacobian support comes
5051 // from the form: `Hx + a` is nonzero exactly on the union of the
5052 // Hessian's rows and the folded linear part.
5053 if let Some(f) = quad.row_form(i) {
5054 cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
5055 }
5056 cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
5057 cols.sort_unstable();
5058 cols.dedup();
5059 cols.shrink_to_fit();
5060 jac_nnz += cols.len();
5061 jac_cols.push(cols);
5062 }
5063
5064 let mut max_tape_n: usize = 0;
5065 for t in &obj_tapes {
5066 max_tape_n = max_tape_n.max(t.ops.len());
5067 }
5068 for row in &con_tapes {
5069 for t in row {
5070 max_tape_n = max_tape_n.max(t.ops.len());
5071 }
5072 }
5073
5074 if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
5075 // Mean number of Hessian colors a single constraint tape touches.
5076 // It is the cost multiplier of visiting the work color-major
5077 // rather than tape-major: each (tape, color) pair needs the
5078 // tape's forward values, so a color-major walk forwards each tape
5079 // this many times instead of once.
5080 let (mut tape_color_pairs, mut n_live_tapes) = (0usize, 0usize);
5081 for row in &con_tape_colors {
5082 for cols in row {
5083 if !cols.is_empty() {
5084 tape_color_pairs += cols.len();
5085 n_live_tapes += 1;
5086 }
5087 }
5088 }
5089 let colors_per_tape = tape_color_pairs as f64 / n_live_tapes.max(1) as f64;
5090 let n_obj = obj_tapes.len();
5091 let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
5092 let total = n_obj + n_con;
5093 let mut sum_ops: usize = 0;
5094 for t in &obj_tapes {
5095 sum_ops += t.ops.len();
5096 }
5097 for row in &con_tapes {
5098 for t in row {
5099 sum_ops += t.ops.len();
5100 }
5101 }
5102 let t = total.max(1);
5103 let nnz_h = h_irow.len();
5104 let avg_decode =
5105 decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
5106 eprintln!(
5107 "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
5108 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
5109 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h} \
5110 colors_per_tape={colors_per_tape:.2}",
5111 sum_ops as f64 / t as f64,
5112 );
5113 // Flat vs shared-CSE op counts for the constraint block. The
5114 // ratio is how much duplicated CSE work `eval_g`'s hybrid path
5115 // avoids, and the ceiling on what routing the Jacobian /
5116 // Hessian through the same prelude could save.
5117 match &con_hybrid {
5118 Some(h) => {
5119 let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
5120 let prelude = h.tape.n_prelude_ops();
5121 let local = h.tape.total_local_ops();
5122 eprintln!(
5123 "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
5124 local_ops={local} shared_total={} flat/shared={:.2}x \
5125 jac_gate={} hess_gate={}",
5126 prelude + local,
5127 flat as f64 / (prelude + local).max(1) as f64,
5128 if h.use_for_jac { "on" } else { "off" },
5129 if h.use_for_hess { "on" } else { "off" },
5130 );
5131 }
5132 None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
5133 }
5134 // What the constant-structure path took off the tape builder.
5135 // `forms` counts the objective too, which is why it can exceed
5136 // the row count by one.
5137 let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
5138 // How many of those forms the *parser* produced, i.e. how many
5139 // never cost an `Expr` (gh #588, Q5). The rest were recognized
5140 // from a tree that was built: a `from_expressions` model, or a
5141 // body the parser rewound because it was not degree 2.
5142 let parsed = std::iter::once(&prob.obj_nonlinear)
5143 .chain(prob.con_nonlinear.iter())
5144 .filter(|b| b.quad().is_some())
5145 .count();
5146 eprintln!(
5147 "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
5148 stored_h_entries={} colored_pairs={}/{}",
5149 quad.len(),
5150 prob.m,
5151 if quad.objective_form().is_some() {
5152 "quadratic"
5153 } else {
5154 "taped"
5155 },
5156 quad.stored_entries(),
5157 if h_tape_mask.is_empty() {
5158 lower_pairs.len()
5159 } else {
5160 h_tape_mask.iter().filter(|&&t| t).count()
5161 },
5162 lower_pairs.len(),
5163 );
5164 }
5165
5166 let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
5167
5168 let mut me = Self {
5169 prob,
5170 obj_tapes,
5171 con_tapes,
5172 con_hybrid,
5173 quad,
5174 h_tape_mask,
5175 h_irow,
5176 h_jcol,
5177 jac_cols,
5178 jac_nnz,
5179 seeds,
5180 decoding,
5181 obj_tape_colors,
5182 con_tape_colors,
5183 hess_color_index,
5184 jac_row_off: Vec::new(),
5185 #[cfg(not(target_arch = "wasm32"))]
5186 jac_scratch_pool: Vec::new(),
5187 var_color,
5188 peeled_cols,
5189 final_x: None,
5190 final_obj: 0.0,
5191 final_lambda: None,
5192 final_z_l: None,
5193 final_z_u: None,
5194 scratch_row_grad: Vec::new(),
5195 vals_scratch: vec![0.0; max_tape_n],
5196 dot_scratch: vec![0.0; max_tape_n],
5197 adj_scratch: vec![0.0; max_tape_n],
5198 adj_dot_scratch: vec![0.0; max_tape_n],
5199 compressed,
5200 hvp_live: Vec::new(),
5201 curvature_scaling: None,
5202 };
5203 me.veto_ill_conditioned_peels();
5204 Ok(me)
5205 }
5206
5207 /// Un-peel any dense column whose own pass is too ill-scaled to read
5208 /// its small entries out of, and re-color if that changes anything.
5209 ///
5210 /// `greedy_hessian_coloring` picks the peel set from structure alone,
5211 /// which is the right call for the memory and the color count but
5212 /// blind to the one thing that can go wrong: an entry recovered from
5213 /// column `d`'s pass inherits that pass's roundoff floor, about
5214 /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
5215 /// well-scaled dense column loses nothing to that — the recovered
5216 /// entries come back bit-identical to the uncompressed reference on
5217 /// every peel-firing model in the benchmark corpus but one. A column
5218 /// spanning many orders of magnitude, though, hands its small entries
5219 /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
5220 /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
5221 /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
5222 /// survives that, but the multipliers come out of the KKT system the
5223 /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
5224 /// the solve stalls short of `Optimal` on a problem it used to
5225 /// certify.
5226 ///
5227 /// Nothing structural distinguishes the two cases, so measure it: one
5228 /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
5229 /// column's exact pass sitting in `compressed`, and a column whose
5230 /// worst recovered entry would lose more than
5231 /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
5232 /// one `eval_h` — at the peeled color count, so cheap — and only for
5233 /// the ~3% of models that peel anything at all.
5234 fn veto_ill_conditioned_peels(&mut self) {
5235 if self.peeled_cols.is_empty() {
5236 return;
5237 }
5238
5239 let mut values = vec![0.0; self.h_irow.len()];
5240 let lambda = vec![1.0; self.prob.m];
5241 let x0 = self.prob.x0.clone();
5242 if !self.eval_h(
5243 Some(&x0),
5244 true,
5245 1.0,
5246 Some(&lambda),
5247 true,
5248 SparsityRequest::Values {
5249 values: &mut values,
5250 },
5251 ) {
5252 return;
5253 }
5254
5255 let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
5256 // A column whose whole pass is negligible against the Hessian as a
5257 // whole cannot move the KKT system no matter how badly its own
5258 // entries are rounded, and columns that are identically zero at
5259 // `x0` would otherwise veto on a ratio of pure noise.
5260 let h_scale = self
5261 .compressed
5262 .iter()
5263 .flat_map(|c| c.iter())
5264 .fold(0.0f64, |a, &v| a.max(v.abs()));
5265 let mut peel_veto = vec![false; self.prob.n];
5266 let mut vetoed = 0usize;
5267 for &d in &self.peeled_cols {
5268 let c = self.var_color[d as usize];
5269 if c == u32::MAX {
5270 continue;
5271 }
5272 let pass = &self.compressed[c as usize];
5273 // The floor the pass was accumulated at, against the smallest
5274 // entry actually read out of it.
5275 let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
5276 // Entries at or below the pass's own roundoff floor carry no
5277 // information to lose: `eps * scale` is the noise the pass was
5278 // accumulated at, so such an entry is already indistinguishable
5279 // from zero whether or not the column is peeled. Including them
5280 // would divide by that noise -- `orthregd` holds entries of
5281 // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
5282 // 4e-16 against an uncompressed reference.
5283 let noise = f64::EPSILON * scale;
5284 let smallest = self.decoding[c as usize]
5285 .iter()
5286 .map(|w| pass[w.row as usize].abs())
5287 .filter(|v| *v > noise)
5288 .fold(f64::INFINITY, f64::min);
5289 if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
5290 continue;
5291 }
5292 if scale <= h_scale * f64::EPSILON {
5293 continue;
5294 }
5295 let rel_err = f64::EPSILON * scale / smallest;
5296 if dbg {
5297 eprintln!(
5298 "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
5299 min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
5300 if rel_err > PEEL_MAX_REL_ERR {
5301 " VETO"
5302 } else {
5303 ""
5304 }
5305 );
5306 }
5307 if rel_err > PEEL_MAX_REL_ERR {
5308 peel_veto[d as usize] = true;
5309 vetoed += 1;
5310 }
5311 }
5312
5313 if vetoed == 0 {
5314 return;
5315 }
5316 if dbg {
5317 eprintln!(
5318 "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
5319 self.peeled_cols.len()
5320 );
5321 }
5322 self.recolor(&peel_veto);
5323 }
5324
5325 /// Rebuild the coloring and everything keyed off it, barring
5326 /// `peel_veto` from the peel set.
5327 fn recolor(&mut self, peel_veto: &[bool]) {
5328 let lower_pairs: Vec<(usize, usize)> = self
5329 .h_irow
5330 .iter()
5331 .zip(&self.h_jcol)
5332 .map(|(&i, &j)| (i as usize, j as usize))
5333 .collect();
5334 let ColorTables {
5335 var_color,
5336 n_colors,
5337 peeled_cols,
5338 seeds,
5339 decoding,
5340 obj_tape_colors,
5341 con_tape_colors,
5342 hess_color_index,
5343 } = build_color_tables(
5344 self.prob.n,
5345 self.prob.m,
5346 &lower_pairs,
5347 &self.h_tape_mask,
5348 peel_veto,
5349 &self.obj_tapes,
5350 &self.con_tapes,
5351 self.con_hybrid.as_mut(),
5352 );
5353 self.var_color = var_color;
5354 self.peeled_cols = peeled_cols;
5355 self.seeds = seeds;
5356 self.decoding = decoding;
5357 self.obj_tape_colors = obj_tape_colors;
5358 self.con_tape_colors = con_tape_colors;
5359 // Recoloring invalidates the color-major index: its entries name
5360 // colors, so a stale one would send a row's contribution to whatever
5361 // color now holds that slot. Refreshed here, not rebuilt lazily,
5362 // because nothing downstream can detect the staleness.
5363 self.hess_color_index = hess_color_index;
5364 self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
5365 }
5366
5367 pub fn final_x(&self) -> Option<&[Number]> {
5368 self.final_x.as_deref()
5369 }
5370
5371 pub fn final_obj(&self) -> Number {
5372 self.final_obj
5373 }
5374
5375 /// Converged constraint multipliers from the last solve, in original
5376 /// `.nl` row order. `None` before a solve finishes. See
5377 /// [`Self::final_x`] for the primal counterpart.
5378 pub fn final_lambda(&self) -> Option<&[Number]> {
5379 self.final_lambda.as_deref()
5380 }
5381
5382 /// Converged lower / upper bound multipliers from the last solve, in
5383 /// original `.nl` variable order and Ipopt's internal convention (both
5384 /// `>= 0`). `None` before a solve finishes.
5385 pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
5386 Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
5387 }
5388
5389 /// The parsed problem this TNLP evaluates (bounds, starting point,
5390 /// names, suffixes). Read-only; per-instance overrides go through
5391 /// [`Self::variant`].
5392 pub fn problem(&self) -> &NlProblem {
5393 &self.prob
5394 }
5395
5396 /// Opt this model in to **curvature-based** scaling (gh #703): compute
5397 /// the per-variable and per-row factors of
5398 /// [`crate::nl_scaling::curvature_scaling`] and serve them from
5399 /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
5400 /// them through the channel it already has for user factors.
5401 ///
5402 /// Returns `false` when the model is not one the scheme is defined for
5403 /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
5404 /// exists. The caller must surface that as an error rather than solving
5405 /// unscaled: an accepted scaling option that is then quietly not applied
5406 /// is exactly the gh #483 failure.
5407 ///
5408 /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
5409 /// passes over the magnitude surrogates, and nothing at all for a solve
5410 /// that never calls it.
5411 pub fn enable_curvature_scaling(&mut self) -> bool {
5412 match crate::nl_scaling::curvature_scaling(&self.prob) {
5413 Some(sc) => {
5414 self.curvature_scaling = Some(sc);
5415 true
5416 }
5417 None => false,
5418 }
5419 }
5420
5421 /// Whether [`Self::enable_curvature_scaling`] has been called and
5422 /// succeeded.
5423 pub fn curvature_scaling_enabled(&self) -> bool {
5424 self.curvature_scaling.is_some()
5425 }
5426
5427 /// Whether the enabled curvature scaling actually read any curvature —
5428 /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
5429 /// scaling is not enabled, and `false` for a degree-≤2 model whose every
5430 /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
5431 /// equilibration of `[A b]`.
5432 pub fn curvature_scaling_read_curvature(&self) -> bool {
5433 self.curvature_scaling
5434 .as_ref()
5435 .is_some_and(|sc| sc.quadratic)
5436 }
5437
5438 /// Is constraint row `i` evaluated from a constant quadratic form
5439 /// rather than from an AD tape (gh #588, Q4)?
5440 ///
5441 /// Structural, so it answers before any evaluation. Exposed for the
5442 /// differential test, which has to know which models exercise the fast
5443 /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
5444 pub fn quadratic_row(&self, i: usize) -> bool {
5445 self.quad.row_form(i).is_some()
5446 }
5447
5448 /// As [`Self::quadratic_row`], for the objective.
5449 pub fn quadratic_objective(&self) -> bool {
5450 self.quad.objective_form().is_some()
5451 }
5452
5453 /// Structural set of variables that appear in *some* nonlinear part —
5454 /// the union of `collect_vars` over the objective and every constraint
5455 /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5456 /// list below so the two can never disagree.
5457 fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5458 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5459 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5460 for row in &self.prob.con_nonlinear {
5461 row.collect_vars(&mut nonlinear);
5462 }
5463 nonlinear
5464 }
5465
5466 /// The nonlinear-variable list published through the TNLP contract
5467 /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5468 /// ascending, in the C index style [`NlTnlp`] reports.
5469 ///
5470 /// The contract's asymmetry decides how this is computed. A consumer
5471 /// treats every variable *absent* from the list as linear — Ipopt's
5472 /// limited-memory Hessian skips the quasi-Newton update in that
5473 /// subspace — so naming too few variables is a wrong answer, while
5474 /// naming too many merely costs work. Hence:
5475 ///
5476 /// * When the `.nl` header says **every** variable is nonlinear, publish
5477 /// that and skip the walk. This is the maximally conservative answer,
5478 /// so it is sound whatever the header's provenance, and it is the
5479 /// common case for the models this matters on (`eigena2`: 110 of 110).
5480 /// * Otherwise walk the trees. The header's prefix
5481 /// (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5482 /// answer, but it would be one that *trusts* the writer to have
5483 /// ordered the variables as the format requires, and the walk is
5484 /// already paid once per solve by `get_variables_linearity`. The
5485 /// walk is also the only option for a model built through
5486 /// [`NlProblem::from_expressions`], which has no header at all.
5487 ///
5488 /// The two disagree only in the safe direction, which
5489 /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5490 /// fixture corpus: the walked set always sits inside the header's
5491 /// prefix, because `parse_nl_text` folds constant `C` bodies away
5492 /// (`gh #492`) and AMPL's own census predates that fold.
5493 fn nonlinear_variables(&self) -> Vec<Index> {
5494 if let Some(c) = self.prob.nl_counts
5495 && c.nonlinear_vars() >= self.prob.n
5496 {
5497 return (0..self.prob.n as Index).collect();
5498 }
5499 self.nonlinear_var_set()
5500 .into_iter()
5501 .map(|i| i as Index)
5502 .collect()
5503 }
5504
5505 /// Mutable access to that same problem, for a caller that owns this
5506 /// TNLP outright.
5507 ///
5508 /// The tapes were built from the expressions in [`Self::problem`] and
5509 /// are not rebuilt, so editing an expression here does **not** change
5510 /// what this TNLP evaluates. It exists for teardown: the Python
5511 /// binding takes the expression trees out through here so a deeply
5512 /// nested one is dropped on a stack chosen for it rather than
5513 /// recursively on whatever thread collected the object (pounce#472).
5514 pub fn problem_mut(&mut self) -> &mut NlProblem {
5515 &mut self.prob
5516 }
5517
5518 /// Hessian-vector product of the Lagrangian:
5519 /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5520 ///
5521 /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5522 /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5523 /// compressed columns into the sparse lower triangle; here the seed is
5524 /// the caller's `v` directly, so it is a single forward-over-reverse
5525 /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5526 /// chromatic number. That is what makes it usable on models where
5527 /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5528 /// truncated-CG step only ever needs `∇²L · v`.
5529 ///
5530 /// Sign convention matches `eval_h` and the rest of this evaluator: a
5531 /// `maximize` model's objective is negated so the returned operator is
5532 /// the one that minimizing solves. `lambda` is `None` for the objective
5533 /// block alone.
5534 ///
5535 /// `out` is overwritten (not accumulated into). Errors on any length
5536 /// mismatch rather than panicking, since the Python binding hands this
5537 /// arbitrary user arrays.
5538 pub fn hessian_vector_product(
5539 &mut self,
5540 x: &[Number],
5541 v: &[Number],
5542 obj_factor: Number,
5543 lambda: Option<&[Number]>,
5544 out: &mut [Number],
5545 ) -> Result<(), String> {
5546 self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5547 }
5548
5549 /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5550 /// once, `out[:, c] = ∇²L · v[:, c]`.
5551 ///
5552 /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5553 /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5554 ///
5555 /// Worth having as its own entry point rather than a loop over the
5556 /// single-vector call: the forward sweep depends only on `x`, so a block
5557 /// runs it *once per tape* and reuses `vals` across all `k` directions,
5558 /// where `k` separate calls would redo it `k` times. Only the
5559 /// forward-tangent + reverse-over-tangent passes are per-direction. That
5560 /// is the shape a block-Krylov solve, a directional-derivative probe, or
5561 /// a densify-the-Hessian loop wants.
5562 ///
5563 /// An all-zero direction is skipped, so passing a sparse block whose
5564 /// columns are mostly empty costs only the columns that carry signal.
5565 /// (The sparsity that dominates is the model's own: each tape touches
5566 /// only its own variables, and `hessian_directional` is O(tape ops), not
5567 /// O(n).)
5568 pub fn hessian_vector_products(
5569 &mut self,
5570 x: &[Number],
5571 v: &[Number],
5572 k: usize,
5573 obj_factor: Number,
5574 lambda: Option<&[Number]>,
5575 out: &mut [Number],
5576 ) -> Result<(), String> {
5577 let (n, m) = (self.prob.n, self.prob.m);
5578 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5579 if got == want {
5580 Ok(())
5581 } else {
5582 Err(format!(
5583 "hessian_vector_product: {name} has length {got}, expected {want}"
5584 ))
5585 }
5586 };
5587 check("x", x.len(), n)?;
5588 check("v", v.len(), n * k)?;
5589 check("out", out.len(), n * k)?;
5590 if let Some(lam) = lambda {
5591 check("lambda", lam.len(), m)?;
5592 }
5593
5594 out.fill(0.0);
5595 if k == 0 || n == 0 {
5596 return Ok(());
5597 }
5598
5599 // Which directions carry signal. Computed once, not per (tape,
5600 // direction) pair — with many small summand tapes the scan would
5601 // otherwise dominate the work it is meant to save. Reuses the
5602 // persistent mask so a Krylov loop allocates nothing per iteration.
5603 self.hvp_live.clear();
5604 self.hvp_live
5605 .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5606 if !self.hvp_live.iter().any(|&l| l) {
5607 return Ok(());
5608 }
5609
5610 let obj_seed = if self.prob.minimize {
5611 obj_factor
5612 } else {
5613 -obj_factor
5614 };
5615 // The constant blocks. `H · v` for a quadratic form is a matvec
5616 // against stored values, so unlike a tape it needs no forward sweep
5617 // and no `x` at all. Easy to forget — a matrix-free solve that
5618 // silently dropped the quadratic part of `∇²L` would still converge,
5619 // just to a different point by a different route, which is the
5620 // failure mode this series is most exposed to.
5621 if !self.quad.is_empty() {
5622 if obj_seed != 0.0 {
5623 if let Some(f) = self.quad.objective_form() {
5624 for (c, out_col) in out.chunks_mut(n).enumerate() {
5625 if self.hvp_live[c] {
5626 self.quad.add_hessian_vector(
5627 f,
5628 &v[c * n..(c + 1) * n],
5629 obj_seed,
5630 out_col,
5631 );
5632 }
5633 }
5634 }
5635 }
5636 if let Some(lam) = lambda {
5637 for (i, &w) in lam.iter().enumerate() {
5638 if w == 0.0 {
5639 continue;
5640 }
5641 let Some(f) = self.quad.row_form(i) else {
5642 continue;
5643 };
5644 for (c, out_col) in out.chunks_mut(n).enumerate() {
5645 if self.hvp_live[c] {
5646 self.quad
5647 .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5648 }
5649 }
5650 }
5651 }
5652 }
5653
5654 if obj_seed != 0.0 {
5655 for t in &self.obj_tapes {
5656 if t.ops.is_empty() {
5657 continue;
5658 }
5659 // Once per tape, not once per direction — the whole point
5660 // of the block form.
5661 t.forward_into(x, &mut self.vals_scratch);
5662 for (c, out_col) in out.chunks_mut(n).enumerate() {
5663 if !self.hvp_live[c] {
5664 continue;
5665 }
5666 t.hessian_directional(
5667 &self.vals_scratch,
5668 &v[c * n..(c + 1) * n],
5669 obj_seed,
5670 out_col,
5671 &mut self.dot_scratch,
5672 &mut self.adj_scratch,
5673 &mut self.adj_dot_scratch,
5674 );
5675 }
5676 }
5677 }
5678
5679 if let Some(lam) = lambda {
5680 // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5681 // range for every k.
5682 for (i, &w) in lam.iter().enumerate() {
5683 if w == 0.0 {
5684 continue;
5685 }
5686 for t in &self.con_tapes[i] {
5687 if t.ops.is_empty() {
5688 continue;
5689 }
5690 t.forward_into(x, &mut self.vals_scratch);
5691 for (c, out_col) in out.chunks_mut(n).enumerate() {
5692 if !self.hvp_live[c] {
5693 continue;
5694 }
5695 t.hessian_directional(
5696 &self.vals_scratch,
5697 &v[c * n..(c + 1) * n],
5698 w,
5699 out_col,
5700 &mut self.dot_scratch,
5701 &mut self.adj_scratch,
5702 &mut self.adj_dot_scratch,
5703 );
5704 }
5705 }
5706 }
5707 }
5708
5709 Ok(())
5710 }
5711
5712 /// Clone this TNLP with per-instance overrides applied — the
5713 /// "one structure, many bound / starting-point variations" case of
5714 /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5715 /// coloring are reused via `Clone` (they depend only on the model
5716 /// structure, which a variation cannot change); only the values in
5717 /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5718 /// are replaced. Any stale `final_x` from a previous solve of
5719 /// `self` is cleared on the clone.
5720 ///
5721 /// Errors when an override's length does not match the model
5722 /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5723 pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5724 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5725 if got == want {
5726 Ok(())
5727 } else {
5728 Err(format!(
5729 "NlVariation.{name} has length {got}, expected {want}"
5730 ))
5731 }
5732 };
5733 let mut out = self.clone();
5734 out.final_x = None;
5735 out.final_obj = 0.0;
5736 out.final_lambda = None;
5737 out.final_z_l = None;
5738 out.final_z_u = None;
5739 if let Some(x0) = &v.x0 {
5740 check("x0", x0.len(), self.prob.n)?;
5741 out.prob.x0.clone_from(x0);
5742 }
5743 if let Some(x_l) = &v.x_l {
5744 check("x_l", x_l.len(), self.prob.n)?;
5745 out.prob.x_l.clone_from(x_l);
5746 }
5747 if let Some(x_u) = &v.x_u {
5748 check("x_u", x_u.len(), self.prob.n)?;
5749 out.prob.x_u.clone_from(x_u);
5750 }
5751 if let Some(g_l) = &v.g_l {
5752 check("g_l", g_l.len(), self.prob.m)?;
5753 out.prob.g_l.clone_from(g_l);
5754 }
5755 if let Some(g_u) = &v.g_u {
5756 check("g_u", g_u.len(), self.prob.m)?;
5757 out.prob.g_u.clone_from(g_u);
5758 }
5759 Ok(out)
5760 }
5761
5762 /// Build one [`NlTnlp`] per variation, sharing this instance's
5763 /// structure (see [`Self::variant`]). Returns instances in input
5764 /// order; errors on the first length-mismatched variation.
5765 pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5766 vs.iter().map(|v| self.variant(v)).collect()
5767 }
5768}
5769
5770/// Per-instance overrides for building a family of related NLP
5771/// instances from one parsed `.nl` model (pounce#126): same structure
5772/// and tapes, different starting point and/or bounds — parametric
5773/// sweeps, multi-start, or branch-and-bound node relaxations where
5774/// each node only tightens variable bounds. `None` keeps the base
5775/// model's value.
5776#[derive(Debug, Clone, Default)]
5777pub struct NlVariation {
5778 pub x0: Option<Vec<Number>>,
5779 pub x_l: Option<Vec<Number>>,
5780 pub x_u: Option<Vec<Number>>,
5781 pub g_l: Option<Vec<Number>>,
5782 pub g_u: Option<Vec<Number>>,
5783}
5784
5785impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5786 /// Per-`.nl`-row constraint expression tape, with the linear
5787 /// part folded in. Returns `None` for constraints that contribute
5788 /// neither a nonlinear expression nor any linear coefficients
5789 /// (so FBBT skips them — there's nothing to tighten).
5790 fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5791 if i >= self.prob.con_nonlinear.len() {
5792 return None;
5793 }
5794 let nonlinear = self.prob.con_expr(i);
5795 let linear = self
5796 .prob
5797 .con_linear
5798 .get(i)
5799 .map(|v| v.as_slice())
5800 .unwrap_or(&[]);
5801 // FBBT needs the tree, so a recognized body is rebuilt here. It is
5802 // rebuilt once per row per presolve pass and dropped again, so the
5803 // DAG never all exists at once — which is the property the phase is
5804 // actually about. Translating the stored coefficients instead would
5805 // propagate bounds through a different association and change the
5806 // tightening; that is not a trade this phase makes.
5807 crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
5808 }
5809
5810 /// Variable name from the sibling `.col` file, if one was loaded.
5811 /// Index is original `.nl` column order.
5812 fn variable_name(&self, i: usize) -> Option<&str> {
5813 self.prob.var_names.get(i).map(String::as_str)
5814 }
5815
5816 /// Constraint name from the sibling `.row` file, if one was loaded.
5817 /// Index is original `.nl` row order.
5818 fn constraint_name(&self, i: usize) -> Option<&str> {
5819 self.prob.con_names.get(i).map(String::as_str)
5820 }
5821}
5822
5823/// Per-worker scratch for the parallel Hessian walk. Four buffers of
5824/// `max_tape_n` — the largest tape's node count, tens of entries on the models
5825/// this path is for — so a worker's copy costs about a kilobyte.
5826#[cfg(not(target_arch = "wasm32"))]
5827struct HessScratch {
5828 vals: Vec<f64>,
5829 dot: Vec<f64>,
5830 adj: Vec<f64>,
5831 adj_dot: Vec<f64>,
5832}
5833
5834/// Per-worker scratch for the parallel Jacobian walk: one gradient row of
5835/// length `n` plus the two small tape arenas `gradient_seed_into` fills.
5836#[cfg(not(target_arch = "wasm32"))]
5837#[derive(Debug, Clone)]
5838struct JacScratch {
5839 row_grad: Vec<f64>,
5840 vals: Vec<f64>,
5841 adj: Vec<f64>,
5842}
5843
5844#[cfg(not(target_arch = "wasm32"))]
5845impl JacScratch {
5846 fn new(n: usize, max_tape_n: usize) -> Self {
5847 Self {
5848 row_grad: vec![0.0; n],
5849 vals: vec![0.0; max_tape_n],
5850 adj: vec![0.0; max_tape_n],
5851 }
5852 }
5853}
5854
5855#[cfg(not(target_arch = "wasm32"))]
5856impl HessScratch {
5857 fn new(n: usize) -> Self {
5858 Self {
5859 vals: vec![0.0; n],
5860 dot: vec![0.0; n],
5861 adj: vec![0.0; n],
5862 adj_dot: vec![0.0; n],
5863 }
5864 }
5865}
5866
5867impl NlTnlp {
5868 /// Smallest (row, tape) × color work list that earns a parallel walk.
5869 /// Below it the rayon dispatch costs more than the sweeps it splits, and
5870 /// the serial tape-major walk is also cheaper per unit work because it
5871 /// forwards each tape once rather than once per color.
5872 ///
5873 /// Measured on a banded synthetic (`sin(x_i·x_{i+1}) + x_{i+1}²·x_{i+2}`
5874 /// per row, the model in `parallel_hessian_matches_serial.rs`), serial ->
5875 /// parallel with the gate forced open:
5876 ///
5877 /// | pairs | serial | parallel | ratio |
5878 /// |---|---|---|---|
5879 /// | 400 | 0.028 ms | 0.036 ms | 0.79x |
5880 /// | 1 200 | 0.073 ms | 0.066 ms | 1.11x |
5881 /// | 4 000 | 0.179 ms | 0.112 ms | 1.60x |
5882 /// | 12 000 | 0.344 ms | 0.203 ms | 1.69x |
5883 ///
5884 /// The crossover sits between 400 and 1 200, hence this value.
5885 ///
5886 /// **What that table does not say is how big the win gets.** That model
5887 /// has three Hessian colors, so it has three tasks to hand out however
5888 /// many cores are free, and 1.7x is near its ceiling. The parallelism
5889 /// available here *is* the color count: an AC-SCOPF model with 28 colors
5890 /// runs the same walk 4.9x faster (108.3 ms -> 22.2 ms at 54 859
5891 /// variables, 421.0 ms -> 89.0 ms at 209 755). Gating on pair count alone
5892 /// is therefore conservative in the safe direction — at equal pairs, more
5893 /// colors can only help.
5894 const HESS_PAR_MIN_PAIRS: usize = 1024;
5895
5896 /// Whether the flat Hessian walk runs color-major and in parallel.
5897 ///
5898 /// `POUNCE_NL_PARALLEL_EVAL` overrides the size gate: `0` forces the
5899 /// serial walk, `1` forces the parallel one whatever the size. The two
5900 /// produce bit-identical values, so this is a lever for measurement and
5901 /// for pinning a suspected parallelism bug, not a correctness switch —
5902 /// and `1` is how the threshold below was measured, since the gate would
5903 /// otherwise hide the small end of the curve.
5904 #[cfg(not(target_arch = "wasm32"))]
5905 fn hess_flat_parallel(&self) -> bool {
5906 // More than one color is a hard requirement, not a heuristic: with one
5907 // color there is one task and the inversion is pure overhead.
5908 let multi_color = self.compressed.len() > 1;
5909 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
5910 Ok("0") | Ok("no") | Ok("false") => false,
5911 Ok("1") | Ok("yes") | Ok("true") => multi_color,
5912 _ => multi_color && self.hess_color_index.con_row.len() >= Self::HESS_PAR_MIN_PAIRS,
5913 }
5914 }
5915
5916 /// Smallest Jacobian nonzero count that earns a parallel walk. Same shape
5917 /// of trade as [`Self::HESS_PAR_MIN_PAIRS`], but a cheaper one to cross:
5918 /// the row walk is *not* reordered or duplicated, so the only cost is the
5919 /// dispatch and one length-`n` scratch row per worker.
5920 #[cfg(not(target_arch = "wasm32"))]
5921 const JAC_PAR_MIN_NNZ: usize = 4096;
5922
5923 /// Smallest row count that earns a parallel constraint evaluation. This is
5924 /// the cheapest of the three walks per row — one forward sweep, no
5925 /// gradient, no directional derivative — so the dispatch is a larger share
5926 /// of it and the threshold is the highest of the three.
5927 ///
5928 /// Measured on the banded synthetic, serial -> parallel with the gate
5929 /// forced open: 500 rows 0.34x, 2 000 0.84x, 5 000 1.07x, 8 000 1.53x,
5930 /// 20 000 3.19x, 60 000 4.97x, 200 000 5.78x. The crossover is near 5 000,
5931 /// so the gate sits just under it. Unlike the Hessian, this walk has no
5932 /// color ceiling — every row is its own task — which is why it keeps
5933 /// climbing to ~6x where the Hessian's 3-color model stalls at 1.7x.
5934 #[cfg(not(target_arch = "wasm32"))]
5935 const EVAL_G_PAR_MIN_ROWS: usize = 4096;
5936
5937 /// Whether `eval_g` runs in parallel over rows. Shares
5938 /// `POUNCE_NL_PARALLEL_EVAL` with the other two walks.
5939 #[cfg(not(target_arch = "wasm32"))]
5940 fn eval_g_parallel(&self) -> bool {
5941 // The shared-CSE arm returns earlier; this gate only ever sees the
5942 // flat path.
5943 let big = self.prob.m >= Self::EVAL_G_PAR_MIN_ROWS;
5944 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
5945 Ok("0") | Ok("no") | Ok("false") => false,
5946 Ok("1") | Ok("yes") | Ok("true") => true,
5947 _ => big,
5948 }
5949 }
5950
5951 /// Whether the flat Jacobian walk runs in parallel over row groups.
5952 /// Shares `POUNCE_NL_PARALLEL_EVAL` with the Hessian.
5953 #[cfg(not(target_arch = "wasm32"))]
5954 fn jac_flat_parallel(&self) -> bool {
5955 let big = self.jac_nnz >= Self::JAC_PAR_MIN_NNZ;
5956 match std::env::var("POUNCE_NL_PARALLEL_EVAL").as_deref() {
5957 Ok("0") | Ok("no") | Ok("false") => false,
5958 Ok("1") | Ok("yes") | Ok("true") => true,
5959 _ => big,
5960 }
5961 }
5962
5963 /// The flat Jacobian, walked in parallel over contiguous groups of rows.
5964 ///
5965 /// Unlike the Hessian this needs no inversion: rows are already
5966 /// independent — each zeroes only its own columns of the gradient scratch,
5967 /// accumulates into them, and writes a contiguous run of `values`. So the
5968 /// work is split by *rows*, each group keeping the serial order inside
5969 /// itself, and every row computes exactly what it computed serially. The
5970 /// results are bit-identical for the stronger reason that nothing was
5971 /// reordered at all.
5972 ///
5973 /// Two things are per-worker rather than per-call: the length-`n` gradient
5974 /// row (the reason this is not simply `par_iter`, since `n` can be tens of
5975 /// thousands and allocating one per row would dwarf the work) and the two
5976 /// small tape arenas. They are pooled on `self` and reused across
5977 /// evaluations.
5978 #[cfg(not(target_arch = "wasm32"))]
5979 fn jac_flat_rows_parallel(&mut self, x_arg: Option<&[Number]>, values: &mut [Number]) {
5980 use rayon::prelude::*;
5981 let n = self.prob.n;
5982 let m = self.prob.m;
5983 let groups = rayon::current_num_threads().clamp(1, m.max(1));
5984 let max_tape_n = self.vals_scratch.len();
5985
5986 // Row -> offset of its run in `values`, so a group can be hnaded one
5987 // contiguous slice. Rebuilt only when the shape changes.
5988 if self.jac_row_off.len() != m + 1 {
5989 self.jac_row_off = Vec::with_capacity(m + 1);
5990 let mut acc = 0u32;
5991 for cols in &self.jac_cols {
5992 self.jac_row_off.push(acc);
5993 acc += cols.len() as u32;
5994 }
5995 self.jac_row_off.push(acc);
5996 }
5997 if self.jac_scratch_pool.len() != groups {
5998 self.jac_scratch_pool = (0..groups)
5999 .map(|_| JacScratch::new(n, max_tape_n))
6000 .collect();
6001 }
6002
6003 // Equal-row groups. Rows differ in width, so this is not a perfect
6004 // balance; rayon is given more groups than threads only when that is
6005 // free, and the measurement below is taken with this split.
6006 let per = m.div_ceil(groups.max(1));
6007 let Self {
6008 prob,
6009 con_tapes,
6010 quad,
6011 jac_cols,
6012 jac_row_off,
6013 jac_scratch_pool,
6014 ..
6015 } = self;
6016 let xs = x_arg.unwrap_or(&prob.x0);
6017
6018 // Hand each group its own contiguous run of `values`.
6019 let mut rest: &mut [Number] = values;
6020 let mut slices: Vec<&mut [Number]> = Vec::with_capacity(groups);
6021 for g in 0..groups {
6022 let end_row = ((g + 1) * per).min(m);
6023 let start_row = (g * per).min(m);
6024 let len = (jac_row_off[end_row] - jac_row_off[start_row]) as usize;
6025 let (head, tail) = rest.split_at_mut(len);
6026 slices.push(head);
6027 rest = tail;
6028 }
6029
6030 jac_scratch_pool
6031 .par_iter_mut()
6032 .zip(slices.into_par_iter())
6033 .enumerate()
6034 .for_each(|(g, (sc, out))| {
6035 let (lo, hi) = ((g * per).min(m), ((g + 1) * per).min(m));
6036 let JacScratch {
6037 row_grad,
6038 vals,
6039 adj,
6040 } = sc;
6041 let mut k = 0usize;
6042 for i in lo..hi {
6043 for &j in &jac_cols[i] {
6044 row_grad[j] = 0.0;
6045 }
6046 for t in &con_tapes[i] {
6047 t.gradient_seed_into(xs, 1.0, row_grad, vals, adj);
6048 }
6049 if let Some(f) = quad.row_form(i) {
6050 quad.add_gradient(f, xs, 1.0, row_grad);
6051 }
6052 for &(v, c) in &prob.con_linear[i] {
6053 row_grad[v] += c;
6054 }
6055 for &j in &jac_cols[i] {
6056 out[k] = row_grad[j];
6057 k += 1;
6058 }
6059 }
6060 });
6061 }
6062
6063 /// The flat Hessian's constraint block, walked color-major with one rayon
6064 /// task per color.
6065 ///
6066 /// Each task owns its color's `compressed[c]` outright, so nothing is
6067 /// shared mutably and no reduction is needed. Within a color the pairs are
6068 /// visited in ascending (row, tape) order — the order the serial walk
6069 /// contributes them in — so the sums are identical term by term and the
6070 /// two paths agree bit-for-bit, which
6071 /// `parallel_matches_serial_bit_for_bit` pins.
6072 ///
6073 /// The cost of the inversion is forwarding a tape once per color it
6074 /// touches instead of once for all of them (2.9 colors per tape measured
6075 /// on AC-SCOPF). That is paid back by the parallelism and only above
6076 /// `HESS_PAR_MIN_PAIRS`.
6077 #[cfg(not(target_arch = "wasm32"))]
6078 fn hess_flat_colors_parallel(&mut self, x_arg: Option<&[Number]>, lam: &[Number]) {
6079 use rayon::prelude::*;
6080 let Self {
6081 compressed,
6082 seeds,
6083 con_tapes,
6084 hess_color_index: idx,
6085 vals_scratch,
6086 prob,
6087 ..
6088 } = self;
6089 let x = x_arg.unwrap_or(&prob.x0);
6090 let max_tape_n = vals_scratch.len();
6091 compressed.par_iter_mut().enumerate().for_each_init(
6092 || HessScratch::new(max_tape_n),
6093 |sc, (c, out)| {
6094 let (rows, tapes) = idx.con_pairs(c);
6095 let seed = &seeds[c];
6096 let HessScratch {
6097 vals,
6098 dot,
6099 adj,
6100 adj_dot,
6101 } = sc;
6102 for (&k, &ti) in rows.iter().zip(tapes.iter()) {
6103 let w = lam[k as usize];
6104 if w == 0.0 {
6105 continue;
6106 }
6107 let t = &con_tapes[k as usize][ti as usize];
6108 if t.ops.is_empty() {
6109 continue;
6110 }
6111 t.forward_into(x, vals);
6112 t.hessian_directional(vals, seed, w, out, dot, adj, adj_dot);
6113 }
6114 },
6115 );
6116 }
6117}
6118
6119impl TNLP for NlTnlp {
6120 /// An `.nl` model carries no callback: the CLI and the browser build
6121 /// read the iteration log, not `IterStats` (gh#965).
6122 fn wants_intermediate_callback(&self) -> bool {
6123 false
6124 }
6125
6126 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
6127 Some(NlpInfo {
6128 n: self.prob.n as Index,
6129 m: self.prob.m as Index,
6130 nnz_jac_g: self.jac_nnz as Index,
6131 nnz_h_lag: self.h_irow.len() as Index,
6132 index_style: IndexStyle::C,
6133 })
6134 }
6135
6136 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
6137 b.x_l.copy_from_slice(&self.prob.x_l);
6138 b.x_u.copy_from_slice(&self.prob.x_u);
6139 if !self.prob.g_l.is_empty() {
6140 b.g_l.copy_from_slice(&self.prob.g_l);
6141 b.g_u.copy_from_slice(&self.prob.g_u);
6142 }
6143 true
6144 }
6145
6146 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
6147 sp.x.copy_from_slice(&self.prob.x0);
6148 // The `.nl` `d` segment supplies initial constraint multipliers
6149 // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
6150 // the engine when `warm_start_init_point yes` — by handing them
6151 // back; `OrigIpoptNlp::get_starting_point` then compresses them into
6152 // the algorithm-side y_c / y_d. Without this the warm start silently
6153 // began from zero multipliers, discarding the parsed duals. (Code
6154 // review 2026-06 item M19.)
6155 if sp.init_lambda {
6156 sp.lambda.copy_from_slice(&self.prob.lambda0);
6157 }
6158 // Bound multipliers come from the `ipopt_zL_in` / `ipopt_zU_in`
6159 // variable suffixes, as in Ipopt's `AmplTNLP::get_starting_point`:
6160 // `z_L = sigma * zL_in`, `z_U = -sigma * zU_in` (sigma = -1 for a
6161 // maximize model) — the inverse of the CLI's `ipopt_zL_out` /
6162 // `ipopt_zU_out`, so a `.sol` result fed back in round-trips. A
6163 // suffix the file does not carry leaves that block to the engine's
6164 // defaults, as before.
6165 if sp.init_z {
6166 let sigma = if self.prob.minimize { 1.0 } else { -1.0 };
6167 let sfx = &self.prob.suffixes;
6168 for (name, out, sign) in [
6169 ("ipopt_zL_in", &mut *sp.z_l, sigma),
6170 ("ipopt_zU_in", &mut *sp.z_u, -sigma),
6171 ] {
6172 // A writer may emit an all-integer suffix as integer-kind.
6173 let vals: Option<Vec<Number>> = sfx.var_real.get(name).cloned().or_else(|| {
6174 sfx.var_int
6175 .get(name)
6176 .map(|v| v.iter().map(|&x| x as Number).collect())
6177 });
6178 if let Some(v) = vals.filter(|v| v.len() == out.len()) {
6179 for (o, x) in out.iter_mut().zip(v) {
6180 *o = sign * x;
6181 }
6182 }
6183 }
6184 }
6185 true
6186 }
6187
6188 /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
6189 /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
6190 /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
6191 /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
6192 /// writes into. Before gh#483 nothing implemented this callback for
6193 /// `.nl` input, so a tagged model reached the solver with the option
6194 /// accepted and *no* scaling applied, silently.
6195 ///
6196 /// Returns `false` (engine falls back to no scaling) when the file
6197 /// declares no `scaling_factor` suffix at all — the same "user
6198 /// supplied nothing" answer as the default `TNLP` impl.
6199 ///
6200 /// AMPL suffix vectors default to **0** for components the model did
6201 /// not tag, and 0 is not a usable scale factor. A zero entry is
6202 /// therefore read as "not tagged" and becomes 1.0, which is what
6203 /// "unlisted components are unscaled" means. Per-variable factors
6204 /// are passed straight through: `OrigIpoptNlp` does not model them
6205 /// and refuses the solve with a message rather than dropping them.
6206 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
6207 const NAME: &str = "scaling_factor";
6208 let sfx = &self.prob.suffixes;
6209 let obj = sfx.obj_real.get(NAME);
6210 let var = sfx.var_real.get(NAME);
6211 let con = sfx.con_real.get(NAME);
6212 let computed = self.curvature_scaling.as_ref();
6213 if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
6214 return false;
6215 }
6216 // Curvature-based factors (gh #703) are the *base*; a
6217 // `scaling_factor` suffix the model actually carries overrides
6218 // them component by component, because an explicit factor from the
6219 // modeller beats one inferred from the coefficients. A model with
6220 // no suffixes is the ordinary case and gets the computed vectors
6221 // whole.
6222 if let Some(sc) = computed {
6223 // The length guards are a `copy_from_slice` panic guard, not a
6224 // policy: `curvature_scaling` sizes both vectors from the same
6225 // `NlProblem` this callback is answering for, so a mismatch is a
6226 // bug upstream, not a model the scheme declines. Declining is the
6227 // *worst* available response to it — `use_*_scaling` stays false,
6228 // the engine reads that as "user supplied nothing", and the run
6229 // proceeds unscaled with the option accepted, which is gh #483
6230 // again. Assert it in debug builds so a mismatch is found here,
6231 // where it is one line, instead of as a slow solve later.
6232 debug_assert_eq!(
6233 sc.x.len(),
6234 req.x_scaling.len(),
6235 "curvature x-scaling sized {} for a {}-variable request",
6236 sc.x.len(),
6237 req.x_scaling.len()
6238 );
6239 debug_assert_eq!(
6240 sc.g.len(),
6241 req.g_scaling.len(),
6242 "curvature g-scaling sized {} for a {}-row request",
6243 sc.g.len(),
6244 req.g_scaling.len()
6245 );
6246 if sc.x.len() == req.x_scaling.len() {
6247 req.x_scaling.copy_from_slice(&sc.x);
6248 *req.use_x_scaling = true;
6249 }
6250 if sc.g.len() == req.g_scaling.len() {
6251 req.g_scaling.copy_from_slice(&sc.g);
6252 *req.use_g_scaling = true;
6253 }
6254 }
6255 // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
6256 // are parsed and ignored), so its entry is the objective scale.
6257 *req.obj_scaling = obj
6258 .and_then(|v| v.first().copied())
6259 .filter(|&s| s != 0.0)
6260 .unwrap_or(1.0);
6261 // A zero entry is AMPL's "untagged" default, not a scale factor.
6262 // An untagged component therefore falls back to the base: the
6263 // curvature factor when one was computed, and an explicit 1.0
6264 // otherwise — explicit because the callback's contract is to fill
6265 // the buffer, not to assume the caller pre-filled it with ones.
6266 if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
6267 for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
6268 if s != 0.0 {
6269 *slot = s;
6270 } else if computed.is_none() {
6271 *slot = 1.0;
6272 }
6273 }
6274 *req.use_x_scaling = true;
6275 } else if computed.is_none() {
6276 *req.use_x_scaling = false;
6277 }
6278 if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
6279 for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
6280 if s != 0.0 {
6281 *slot = s;
6282 } else if computed.is_none() {
6283 *slot = 1.0;
6284 }
6285 }
6286 *req.use_g_scaling = true;
6287 } else if computed.is_none() {
6288 *req.use_g_scaling = false;
6289 }
6290 true
6291 }
6292
6293 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
6294 // Reuse the shared forward-value arena (sized to `max_tape_n`) so
6295 // each summand sweep allocates nothing — see `Tape::eval_into`.
6296 let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
6297 let mut nl: Number = 0.0;
6298 for t in obj_tapes {
6299 nl += t.eval_into(x, vals);
6300 }
6301 // A recognized objective has no tapes, so the loop above added
6302 // nothing and the form supplies the whole nonlinear part — including
6303 // the linear and constant terms AMPL folded into that tree, which is
6304 // why they are *not* also in `obj_linear` / `obj_constant`.
6305 if let Some(f) = self.quad.objective_form() {
6306 nl += self.quad.value(f, x);
6307 }
6308 let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
6309 let v = self.prob.obj_constant + nl + lin;
6310 let signed = if self.prob.minimize { v } else { -v };
6311 Some(signed)
6312 }
6313
6314 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
6315 grad.fill(0.0);
6316 // Reuse the forward-value / adjoint scratch arenas (sized to
6317 // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
6318 // nothing — see `Tape::gradient_seed_into` (M18).
6319 for t in &self.obj_tapes {
6320 t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
6321 }
6322 if let Some(f) = self.quad.objective_form() {
6323 self.quad.add_gradient(f, x, 1.0, grad);
6324 }
6325 for (i, c) in &self.prob.obj_linear {
6326 grad[*i] += c;
6327 }
6328 if !self.prob.minimize {
6329 for g in grad.iter_mut() {
6330 *g = -*g;
6331 }
6332 }
6333 true
6334 }
6335
6336 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
6337 // Constraint values are the line search's inner loop: on a
6338 // constraint-heavy model (m >> n) this runs ~10x per iteration over
6339 // every summand tape in the problem. Reuse the shared forward-value
6340 // arena so the sweep allocates nothing — the per-summand `Vec` the
6341 // allocating `Tape::eval` used to build was ~20% of `eval_g` on
6342 // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
6343 // `Tape::eval_into`.
6344 let m = self.prob.m;
6345 let con_linear = &self.prob.con_linear;
6346 let quad = &self.quad;
6347 if let Some(h) = &mut self.con_hybrid {
6348 // Shared CSE bodies once for the whole constraint block, then one
6349 // local sweep per summand (pounce#476).
6350 let ConHybrid {
6351 tape,
6352 row_start,
6353 prelude_vals,
6354 local_vals,
6355 ..
6356 } = h;
6357 tape.forward_prelude(x, prelude_vals);
6358 for i in 0..m {
6359 let mut nl: Number = 0.0;
6360 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6361 tape.forward_summand(s, x, prelude_vals, local_vals);
6362 nl += tape.root_value(s, local_vals);
6363 }
6364 if let Some(f) = quad.row_form(i) {
6365 nl += quad.value(f, x);
6366 }
6367 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6368 g[i] = nl + lin;
6369 }
6370 return true;
6371 }
6372 // Row `i` reads only `x` and writes only `g[i]`, so this one is
6373 // parallel as written — no inversion, no row-offset bookkeeping, and
6374 // the per-row arithmetic is untouched, which is why the values are
6375 // bit-identical. Only the forward-value arena is per-worker.
6376 #[cfg(not(target_arch = "wasm32"))]
6377 if self.eval_g_parallel() {
6378 use rayon::prelude::*;
6379 let max_tape_n = self.vals_scratch.len();
6380 let con_tapes = &self.con_tapes;
6381 g.par_iter_mut().enumerate().for_each_init(
6382 || vec![0.0; max_tape_n],
6383 |vals, (i, gi)| {
6384 let mut nl: Number = 0.0;
6385 for t in &con_tapes[i] {
6386 nl += t.eval_into(x, vals);
6387 }
6388 if let Some(f) = quad.row_form(i) {
6389 nl += quad.value(f, x);
6390 }
6391 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6392 *gi = nl + lin;
6393 },
6394 );
6395 return true;
6396 }
6397 let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
6398 for i in 0..m {
6399 let mut nl: Number = 0.0;
6400 for t in &con_tapes[i] {
6401 nl += t.eval_into(x, vals);
6402 }
6403 // A quadratic row's summand range is empty above, so this is its
6404 // whole nonlinear part: one matvec against a constant matrix in
6405 // place of a walk over every monomial's tape.
6406 if let Some(f) = quad.row_form(i) {
6407 nl += quad.value(f, x);
6408 }
6409 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
6410 g[i] = nl + lin;
6411 }
6412 true
6413 }
6414
6415 fn eval_jac_g(
6416 &mut self,
6417 x: Option<&[Number]>,
6418 _new_x: bool,
6419 mode: SparsityRequest<'_>,
6420 ) -> bool {
6421 match mode {
6422 SparsityRequest::Structure { irow, jcol } => {
6423 let mut k = 0;
6424 for i in 0..self.prob.m {
6425 for &j in &self.jac_cols[i] {
6426 irow[k] = i as Index;
6427 jcol[k] = j as Index;
6428 k += 1;
6429 }
6430 }
6431 true
6432 }
6433 SparsityRequest::Values { values } => {
6434 let n = self.prob.n;
6435 if self.scratch_row_grad.len() < n {
6436 self.scratch_row_grad.resize(n, 0.0);
6437 }
6438 // Rows are independent and write disjoint runs of `values`, so
6439 // the parallel walk is the same work in the same order, split
6440 // by row group. Only the flat path: the shared-CSE arm below
6441 // forwards one prelude for the whole block, which is exactly
6442 // the sharing a row split would have to undo.
6443 #[cfg(not(target_arch = "wasm32"))]
6444 if self.con_hybrid.as_ref().filter(|h| h.use_for_jac).is_none()
6445 && self.jac_flat_parallel()
6446 {
6447 self.jac_flat_rows_parallel(x, values);
6448 return true;
6449 }
6450 let Self {
6451 prob,
6452 con_tapes,
6453 con_hybrid,
6454 quad,
6455 jac_cols,
6456 scratch_row_grad,
6457 vals_scratch,
6458 adj_scratch,
6459 ..
6460 } = self;
6461 let xs = x.unwrap_or(&prob.x0);
6462 let mut k = 0;
6463 // Shared-CSE path: the forward sweep over the CSE bodies runs
6464 // once for the whole constraint block instead of once per
6465 // referencing summand. The reverse sweep cannot be shared —
6466 // each row needs its own gradient — so a summand still walks
6467 // its own `prelude_reach` backwards.
6468 if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
6469 let ConHybrid {
6470 tape,
6471 row_start,
6472 prelude_vals,
6473 local_vals,
6474 local_adj,
6475 prelude_adj,
6476 ..
6477 } = h;
6478 tape.forward_prelude(xs, prelude_vals);
6479 for i in 0..prob.m {
6480 for &j in &jac_cols[i] {
6481 scratch_row_grad[j] = 0.0;
6482 }
6483 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
6484 tape.forward_summand(s, xs, prelude_vals, local_vals);
6485 tape.gradient_summand(
6486 s,
6487 prelude_vals,
6488 local_vals,
6489 1.0,
6490 scratch_row_grad,
6491 local_adj,
6492 prelude_adj,
6493 );
6494 }
6495 if let Some(f) = quad.row_form(i) {
6496 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6497 }
6498 for &(v, c) in &prob.con_linear[i] {
6499 scratch_row_grad[v] += c;
6500 }
6501 for &j in &jac_cols[i] {
6502 values[k] = scratch_row_grad[j];
6503 k += 1;
6504 }
6505 }
6506 return true;
6507 }
6508 for i in 0..prob.m {
6509 for &j in &jac_cols[i] {
6510 scratch_row_grad[j] = 0.0;
6511 }
6512 for t in &con_tapes[i] {
6513 // Allocation-free reverse-AD per summand tape (M18):
6514 // reuse the shared forward/adjoint scratch arenas.
6515 t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
6516 }
6517 // `Hx + a` for a quadratic row: one matvec over the
6518 // row's support, in place of a reverse sweep per
6519 // monomial. `jac_cols[i]` already covers the form's
6520 // gradient support, so the scatter lands inside the
6521 // window zeroed above.
6522 if let Some(f) = quad.row_form(i) {
6523 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
6524 }
6525 for &(v, c) in &prob.con_linear[i] {
6526 scratch_row_grad[v] += c;
6527 }
6528 for &j in &jac_cols[i] {
6529 values[k] = scratch_row_grad[j];
6530 k += 1;
6531 }
6532 }
6533 true
6534 }
6535 }
6536 }
6537
6538 fn eval_h(
6539 &mut self,
6540 x: Option<&[Number]>,
6541 _new_x: bool,
6542 obj_factor: Number,
6543 lambda: Option<&[Number]>,
6544 _new_lambda: bool,
6545 mode: SparsityRequest<'_>,
6546 ) -> bool {
6547 match mode {
6548 SparsityRequest::Structure { irow, jcol } => {
6549 irow.copy_from_slice(&self.h_irow);
6550 jcol.copy_from_slice(&self.h_jcol);
6551 true
6552 }
6553 SparsityRequest::Values { values } => {
6554 // Kept alongside the resolved slice: the parallel walk below
6555 // resolves it again *after* destructuring `self`, so `x` and
6556 // the buffers it writes borrow different fields and neither
6557 // has to be copied per evaluation.
6558 let x_arg = x;
6559 let x = x_arg.unwrap_or(&self.prob.x0);
6560 values.fill(0.0);
6561
6562 let obj_seed = if self.prob.minimize {
6563 obj_factor
6564 } else {
6565 -obj_factor
6566 };
6567 // Coloring path. For each (tape, weight) we do
6568 // one forward pass into `vals_scratch`, then one
6569 // forward-tangent+reverse-over-tangent per color
6570 // touched by that tape. Each pass accumulates a
6571 // weighted contribution of (H_tape · seed_c) into
6572 // `compressed[c]`. After all tapes done, we
6573 // decode each color's compressed vector into the
6574 // sparse `values` array.
6575 for buf in &mut self.compressed {
6576 buf.fill(0.0);
6577 }
6578
6579 // The constant blocks first: no forward sweep, no
6580 // directional product, no decode — the multipliers are the
6581 // only thing that changed since the model was read, so this
6582 // is one `values[slot] += w · h` pass per live form. Skipped
6583 // wholesale on a model with nothing recognized, so such a
6584 // model does not pay an `O(m)` scan for a structure that is
6585 // empty.
6586 if !self.quad.is_empty() {
6587 if obj_seed != 0.0 {
6588 if let Some(f) = self.quad.objective_form() {
6589 self.quad.accumulate_hessian(f, obj_seed, values);
6590 }
6591 }
6592 if let Some(lam) = lambda {
6593 for i in 0..self.prob.m {
6594 let w = lam[i];
6595 if w == 0.0 {
6596 continue;
6597 }
6598 if let Some(f) = self.quad.row_form(i) {
6599 self.quad.accumulate_hessian(f, w, values);
6600 }
6601 }
6602 }
6603 }
6604
6605 if obj_seed != 0.0 {
6606 for (ti, t) in self.obj_tapes.iter().enumerate() {
6607 if t.ops.is_empty() {
6608 continue;
6609 }
6610 t.forward_into(x, &mut self.vals_scratch);
6611 for &c in &self.obj_tape_colors[ti] {
6612 t.hessian_directional(
6613 &self.vals_scratch,
6614 &self.seeds[c as usize],
6615 obj_seed,
6616 &mut self.compressed[c as usize],
6617 &mut self.dot_scratch,
6618 &mut self.adj_scratch,
6619 &mut self.adj_dot_scratch,
6620 );
6621 }
6622 }
6623 }
6624
6625 // Computed before the match: its scrutinee takes `self` mutably,
6626 // so a guard cannot also read `self`. Absent on wasm32 along
6627 // with the arm it guards — the arm still has to *typecheck*
6628 // there, dead or not, and the method it calls does not exist.
6629 #[cfg(not(target_arch = "wasm32"))]
6630 let hess_flat_parallel = self.hess_flat_parallel();
6631 match (lambda, self.con_hybrid.as_mut()) {
6632 // Shared-CSE path (issue #557). Per color the prelude's
6633 // second-order work runs ONCE for the whole constraint
6634 // block: one forward tangent, then — because
6635 // reverse-over-tangent is linear in its adjoint seeds —
6636 // one unit-weight reverse sweep over the λ-weighted
6637 // adjoints accumulated by every summand of that color.
6638 // The flat path below repeats both sweeps over the
6639 // inlined CSE body once per referencing summand.
6640 (Some(lam), Some(h)) if h.use_for_hess => {
6641 let ConHybrid {
6642 tape,
6643 prelude_vals,
6644 local_vals_all,
6645 local_off,
6646 summand_row,
6647 hess_color_summands,
6648 hess_color_reach,
6649 hess_color_reach_off,
6650 prelude_dot,
6651 hess_prelude_adj,
6652 prelude_adj_dot,
6653 local_dot,
6654 local_adj,
6655 local_adj_dot,
6656 ..
6657 } = h;
6658 // Forward once (values are color-independent):
6659 // prelude for the block, then each summand of a row
6660 // with a live multiplier into its packed slice.
6661 tape.forward_prelude(x, prelude_vals);
6662 for (si, s) in tape.summands.iter().enumerate() {
6663 if lam[summand_row[si] as usize] == 0.0 {
6664 continue;
6665 }
6666 tape.forward_summand(
6667 s,
6668 x,
6669 prelude_vals,
6670 &mut local_vals_all[local_off[si]..local_off[si + 1]],
6671 );
6672 }
6673 for (c, list) in hess_color_summands.iter().enumerate() {
6674 if !list
6675 .iter()
6676 .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
6677 {
6678 continue;
6679 }
6680 let seed = &self.seeds[c];
6681 let out = &mut self.compressed[c];
6682 let creach = &hess_color_reach
6683 [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
6684 tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
6685 for &si in list {
6686 let si = si as usize;
6687 let w = lam[summand_row[si] as usize];
6688 if w == 0.0 {
6689 continue;
6690 }
6691 tape.hessian_summand_directional(
6692 &tape.summands[si],
6693 &local_vals_all[local_off[si]..local_off[si + 1]],
6694 prelude_dot,
6695 seed,
6696 w,
6697 out,
6698 local_dot,
6699 local_adj,
6700 local_adj_dot,
6701 hess_prelude_adj,
6702 prelude_adj_dot,
6703 );
6704 }
6705 tape.prelude_reverse_directional(
6706 prelude_vals,
6707 prelude_dot,
6708 creach,
6709 out,
6710 hess_prelude_adj,
6711 prelude_adj_dot,
6712 );
6713 }
6714 }
6715 // Color-major and parallel when the work is big enough
6716 // to pay for the dispatch; bit-identical to the walk
6717 // below, which stays the path every small model takes.
6718 #[cfg(not(target_arch = "wasm32"))]
6719 (Some(lam), _) if hess_flat_parallel => {
6720 self.hess_flat_colors_parallel(x_arg, lam);
6721 }
6722 (Some(lam), _) => {
6723 for k in 0..self.prob.m {
6724 let w = lam[k];
6725 if w == 0.0 {
6726 continue;
6727 }
6728 for (ti, t) in self.con_tapes[k].iter().enumerate() {
6729 if t.ops.is_empty() {
6730 continue;
6731 }
6732 t.forward_into(x, &mut self.vals_scratch);
6733 for &c in &self.con_tape_colors[k][ti] {
6734 t.hessian_directional(
6735 &self.vals_scratch,
6736 &self.seeds[c as usize],
6737 w,
6738 &mut self.compressed[c as usize],
6739 &mut self.dot_scratch,
6740 &mut self.adj_scratch,
6741 &mut self.adj_dot_scratch,
6742 );
6743 }
6744 }
6745 }
6746 }
6747 (None, _) => {}
6748 }
6749
6750 // Decode each color's compressed Hessian-vector
6751 // result into the lower-triangle `values` array.
6752 for (c, table) in self.decoding.iter().enumerate() {
6753 let comp = &self.compressed[c];
6754 for w in table {
6755 values[w.hess_idx as usize] += comp[w.row as usize];
6756 }
6757 }
6758 true
6759 }
6760 }
6761 }
6762
6763 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
6764 self.final_x = Some(sol.x.to_vec());
6765 self.final_obj = sol.obj_value;
6766 self.final_lambda = Some(sol.lambda.to_vec());
6767 self.final_z_l = Some(sol.z_l.to_vec());
6768 self.final_z_u = Some(sol.z_u.to_vec());
6769 }
6770
6771 /// Publish the `.col` / `.row` names (captured at load time) under the
6772 /// conventional `idx_names` metadata key, in original `.nl` order. The
6773 /// adapter permutes these into split space (see
6774 /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
6775 /// near-singular Jacobian row as the `mass_balance` equation rather
6776 /// than "row 3" — the model-vs-index gap Lee et al. (2024,
6777 /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
6778 /// model debugging. Declines (returns false) when the model shipped no
6779 /// name files so callers fall back to index labels.
6780 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
6781 let mut any = false;
6782 if !self.prob.var_names.is_empty() {
6783 var.strings
6784 .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
6785 any = true;
6786 }
6787 if !self.prob.con_names.is_empty() {
6788 con.strings
6789 .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
6790 any = true;
6791 }
6792 any
6793 }
6794
6795 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
6796 // A row is linear iff its nonlinear-part expression is the
6797 // identity zero — either left over from initial allocation ("no
6798 // `C<idx>` segment touched this row") or installed by the
6799 // constant-row-body fold in `parse_nl_text`, which shifts a
6800 // variable-free `C<idx>` body into the row bounds precisely so
6801 // that this test is a genuine linearity test and not just an
6802 // identity check (`gh #492`).
6803 for (i, t) in types.iter_mut().enumerate() {
6804 *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
6805 Linearity::Linear
6806 } else {
6807 Linearity::NonLinear
6808 };
6809 }
6810 true
6811 }
6812
6813 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6814 // Global linearity, per the upstream TNLP contract: a variable is
6815 // NonLinear iff it appears in the nonlinear part of the objective
6816 // or of any constraint; otherwise Linear. The parsed `.nl` splits
6817 // every row into a linear part (J/G coefficient list) and a
6818 // nonlinear expression, so the set of nonlinear variables is
6819 // exactly the structural union of `collect_vars` over
6820 // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
6821 // only by a linear part — or not referenced at all — is Linear.
6822 //
6823 let nonlinear = self.nonlinear_var_set();
6824 for (i, t) in types.iter_mut().enumerate() {
6825 *t = if nonlinear.contains(&i) {
6826 Linearity::NonLinear
6827 } else {
6828 Linearity::Linear
6829 };
6830 }
6831 true
6832 }
6833
6834 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6835 // Objective-scoped variant of `get_variables_linearity`: only
6836 // `obj_nonlinear` contributes. This is what engages the presolve
6837 // auxiliary-elimination safeguard (pounce-presolve H11): a variable
6838 // that is nonlinear in the objective but happens to have a zero
6839 // gradient at the single probe point (e.g. `f = (x - x0)^2`
6840 // warm-started at `x0`) is kept in the objective support instead of
6841 // being mis-classified objective-free and eliminated. A variable
6842 // that is nonlinear only in *constraints* stays `Linear` here, so
6843 // the guard does not block legitimate eliminations of
6844 // objective-free equality blocks (the gas-network case).
6845 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
6846 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
6847 for (i, t) in types.iter_mut().enumerate() {
6848 *t = if nonlinear.contains(&i) {
6849 Linearity::NonLinear
6850 } else {
6851 Linearity::Linear
6852 };
6853 }
6854 true
6855 }
6856
6857 fn get_number_of_nonlinear_variables(&mut self) -> Index {
6858 self.nonlinear_variables().len() as Index
6859 }
6860
6861 fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
6862 let list = self.nonlinear_variables();
6863 if pos_nonlin_vars.len() < list.len() {
6864 return false;
6865 }
6866 pos_nonlin_vars[..list.len()].copy_from_slice(&list);
6867 true
6868 }
6869
6870 fn derivative_proofs(&mut self) -> DerivativeProofs {
6871 // Degree is the whole argument (gh #588, Q6). A body proved
6872 // degree ≤ 1 has a constant gradient and no second derivative; a
6873 // body proved degree 2 has a nonzero second derivative, hence a
6874 // gradient that moves. A body the recognizer refuses is
6875 // `Unknown` — and `Unknown` must stay `Unknown`, because the
6876 // refusal is structural, not a finding of nonlinearity.
6877 fn proof(affine: Option<bool>) -> DerivativeProof {
6878 match affine {
6879 Some(true) => DerivativeProof::Constant,
6880 Some(false) => DerivativeProof::Varying,
6881 None => DerivativeProof::Unknown,
6882 }
6883 }
6884 let obj_affine = self.prob.obj_nonlinear.provably_affine();
6885 let jac: Vec<DerivativeProof> = self
6886 .prob
6887 .con_nonlinear
6888 .iter()
6889 .map(|b| proof(b.provably_affine()))
6890 .collect();
6891
6892 // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
6893 // makes `∇²L` a non-constant function of `λ` — this is the QCQP
6894 // case, and it is exactly the assertion Ipopt would honour and
6895 // get wrong (§4 Lever 2). Otherwise every row must be *proved*
6896 // affine and the objective *proved* degree ≤ 2, at which point
6897 // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
6898 // reuse on `σ` because the restoration phase passes a different
6899 // one.
6900 let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
6901 DerivativeProof::Varying
6902 } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
6903 DerivativeProof::Constant
6904 } else {
6905 DerivativeProof::Unknown
6906 };
6907
6908 DerivativeProofs {
6909 grad_f: proof(obj_affine),
6910 hessian,
6911 jac,
6912 }
6913 }
6914}
6915
6916/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
6917pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
6918 let prob = read_nl_file(path)?;
6919 Ok(Rc::new(RefCell::new(NlTnlp::new(prob))))
6920}
6921
6922#[cfg(test)]
6923mod tests {
6924 /// `.nl` text for `min sum_i exp(x_i) + x_i^2` whose sum is a left-deep
6925 /// chain of `2n - 1` binary `o0`, wrapped in `wraps` pairs of unary `o16`
6926 /// (negation, an even count so the value is unchanged). The `o0` chain is
6927 /// read as one n-ary `Sum`; the negations are what stays genuinely deep.
6928 fn deep_sum_nl(n: usize, wraps: usize) -> String {
6929 let mut l = vec![
6930 "g3 1 1 0".to_string(),
6931 format!(" {n} 1 1 0 1"),
6932 " 0 1".into(),
6933 " 0 0".into(),
6934 format!(" 0 {n} 0"),
6935 " 0 0 0 1 0".into(),
6936 " 0 0 0 0 0".into(),
6937 format!(" {n} {n}"),
6938 " 0 0".into(),
6939 " 0 0 0 0 0".into(),
6940 "C0".into(),
6941 "n0".into(),
6942 "O0 0".into(),
6943 ];
6944 l.extend(std::iter::repeat_n("o16".to_string(), 2 * wraps));
6945 l.extend(std::iter::repeat_n("o0".to_string(), 2 * n - 1));
6946 for i in 0..n {
6947 l.extend([
6948 "o44".into(),
6949 format!("v{i}"),
6950 "o2".into(),
6951 format!("v{i}"),
6952 format!("v{i}"),
6953 ]);
6954 }
6955 l.extend(["r".into(), "4 0.5".into(), "b".into()]);
6956 l.extend((0..n).map(|_| "0 -1.0 1.0".to_string()));
6957 l.push(format!("k{}", n - 1));
6958 l.extend((0..n - 1).map(|i| (i + 1).to_string()));
6959 l.push(format!("J0 {n}"));
6960 l.extend((0..n).map(|i| format!("{i} 1.0")));
6961 l.push(format!("G0 {n}"));
6962 l.extend((0..n).map(|i| format!("{i} 0.0")));
6963 l.join("\n") + "\n"
6964 }
6965
6966 /// Run `f` on a thread with the stack the CLI and `pounce-py` provide,
6967 /// declared to the reader the way they declare it.
6968 fn on_big_stack(f: impl FnOnce() + Send + 'static) {
6969 std::thread::Builder::new()
6970 .stack_size(1 << 30)
6971 .spawn(move || {
6972 set_parse_stack_budget(Some(1 << 30));
6973 f();
6974 set_parse_stack_budget(None);
6975 })
6976 .unwrap()
6977 .join()
6978 .unwrap();
6979 }
6980
6981 /// A model whose objective is the last of `len` defined variables, each
6982 /// referencing the previous: `V_0 = x0^2`, `V_k = V_{k-1} + x0`, so the
6983 /// objective is `x0^2 + (len - 1) x0`. Every `V` segment is one level
6984 /// deep to the parser; the chain is `len` deep to anything that walks
6985 /// through it.
6986 fn v_chain_nl(len: usize) -> String {
6987 let mut l = vec![
6988 "g3 1 1 0".to_string(),
6989 " 1 1 1 0 1".into(),
6990 " 0 1".into(),
6991 " 0 0".into(),
6992 " 0 1 0".into(),
6993 " 0 0 0 1 0".into(),
6994 format!(" 0 0 {len} 0 0"),
6995 " 1 1".into(),
6996 " 0 0".into(),
6997 " 0 0 0 0 0".into(),
6998 ];
6999 l.extend(["V1 0 0".into(), "o5".into(), "v0".into(), "n2".into()]);
7000 for k in 1..len {
7001 l.extend([
7002 format!("V{} 0 0", k + 1),
7003 "o0".into(),
7004 format!("v{k}"),
7005 "v0".into(),
7006 ]);
7007 }
7008 l.extend(["C0".into(), "n0".into()]);
7009 l.extend(["O0 0".into(), format!("v{len}")]);
7010 l.extend(["r".into(), "4 0.5".into(), "b".into(), "0 -1.0 1.0".into()]);
7011 l.extend(["k0".into(), "J0 1".into(), "0 1.0".into()]);
7012 l.extend(["G0 1".into(), "0 0.0".into()]);
7013 l.join("\n") + "\n"
7014 }
7015
7016 /// gh#986 review item 9: a chain of defined variables is counted by the
7017 /// depth guard. Each `V` segment is shallow to the parser, so the first
7018 /// pass let a chain of any length through, and every pass that walks
7019 /// through a `Cse` (`collect_vars`, the tape builder, drop glue) then
7020 /// recursed once per link -- an abort on an ordinary stack. On a thread
7021 /// that declared no stack (the default budget) a chain far past the guard
7022 /// is now a clean `Err`.
7023 #[test]
7024 fn gh986_a_long_defined_variable_chain_is_refused_not_overflowed() {
7025 let limit = parse_depth_limit() as usize;
7026 let err = parse_nl_text(&v_chain_nl(limit + 50))
7027 .err()
7028 .expect("a V chain past the guard must be an Err");
7029 assert!(err.contains("defined") && err.contains("levels"), "{err}");
7030 // A short chain on the same thread is fine, and evaluates.
7031 let prob = parse_nl_text(&v_chain_nl(limit / 2)).expect("inside the guard");
7032 let x = [0.5];
7033 let want = 0.25 + (limit / 2 - 1) as f64 * 0.5;
7034 let got = eval_expr(&prob.obj_expr(), &x);
7035 assert!((got - want).abs() < 1e-9, "{got} vs {want}");
7036 }
7037
7038 /// gh#986 review item 9: the depth of a `V` body is memoized, so a long
7039 /// chain inside the guard costs time linear in the chain (the first
7040 /// pass's `expr_tree_depth` re-walked the whole chain on every segment).
7041 #[test]
7042 fn gh986_a_long_defined_variable_chain_inside_the_guard_parses() {
7043 on_big_stack(|| {
7044 // Two levels per link (the `o0` and the `Cse` reference), just
7045 // inside this thread's limit.
7046 let len = (parse_depth_limit() as usize - 100) / 2;
7047 let t = std::time::Instant::now();
7048 let prob = parse_nl_text(&v_chain_nl(len)).expect("inside the guard on a big stack");
7049 let got = eval_expr(&prob.obj_expr(), &[0.5]);
7050 let want = 0.25 + (len - 1) as f64 * 0.5;
7051 assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
7052 // Linear. With only `expr_tree_depth` memoized, the parse-time
7053 // quadratic passes still re-walked the chain per segment and this
7054 // ran past 60 s (measured, test profile); seeded, ~50 ms.
7055 let took = t.elapsed();
7056 eprintln!("v chain {len}: parsed and evaluated in {took:?}");
7057 assert!(took.as_secs() < 30, "{took:?}");
7058 });
7059 }
7060
7061 /// gh#986 review item 9: a thread that declares no stack is assumed to
7062 /// have Rust's default thread stack (wasm: 1 MiB), not the CLI's 1 GiB.
7063 #[test]
7064 fn gh986_an_undeclared_stack_gets_the_default_assumption() {
7065 std::thread::spawn(|| {
7066 set_parse_stack_budget(None);
7067 assert_eq!(
7068 parse_depth_limit() as usize,
7069 DEFAULT_ASSUMED_STACK / PARSE_LEVEL_BYTES
7070 );
7071 assert!((parse_depth_limit() as usize) < MAX_PARSE_DEPTH as usize);
7072 })
7073 .join()
7074 .unwrap();
7075 }
7076
7077 /// gh#986: a left-deep `o0` chain of any length is read as one n-ary
7078 /// `Sum` — one level deep — so the issue's n = 20 000 (39 999 nested
7079 /// `o0`) parses, and so does 100k, with the same value as the nested
7080 /// binary tree would have had.
7081 #[test]
7082 fn gh986_long_o0_chain_is_read_as_a_flat_sum() {
7083 on_big_stack(|| {
7084 for n in [5_000usize, 20_000, 100_000] {
7085 let prob = parse_nl_text(&deep_sum_nl(n, 0)).expect("a long o0 sum must parse");
7086 let x: Vec<f64> = (0..n).map(|i| (i % 7) as f64 * 0.1).collect();
7087 let want: f64 = x.iter().map(|v| v.exp() + v * v).sum();
7088 let tree = prob.obj_expr();
7089 assert!(expr_tree_depth(&tree, &Default::default()) < 16, "n = {n}");
7090 let got = eval_expr(&tree, &x);
7091 assert!((got - want).abs() <= 1e-9 * want.abs(), "{got} vs {want}");
7092 }
7093 });
7094 }
7095
7096 /// gh#986: the guard follows the stack the thread declares, so a thread
7097 /// with an ordinary 8 MiB stack refuses deep nesting cleanly — in an
7098 /// unoptimised build (the worst frame size) exactly as in an optimised
7099 /// one — instead of overflowing it. This is the reliability the
7100 /// `parse_nl_text` SIGBUS/SIGSEGV reports lacked.
7101 #[test]
7102 fn gh986_guard_follows_the_declared_stack() {
7103 let stack = 8 << 20;
7104 std::thread::Builder::new()
7105 .stack_size(stack)
7106 .spawn(move || {
7107 set_parse_stack_budget(Some(stack));
7108 assert_eq!(parse_depth_limit() as usize, stack / PARSE_LEVEL_BYTES);
7109 let msg = parse_nl_text(&deep_sum_nl(4, 20_000))
7110 .err()
7111 .expect("40 000 levels cannot fit 8 MiB");
7112 assert!(msg.contains("levels"), "{msg}");
7113 // and a shallow model on the same thread is fine
7114 assert!(parse_nl_text(&deep_sum_nl(4, 50)).is_ok());
7115 set_parse_stack_budget(None);
7116 })
7117 .unwrap()
7118 .join()
7119 .unwrap();
7120 }
7121
7122 /// gh#986: nesting that really is deep (negations) past the guard is
7123 /// refused with an `Err`, at any depth, instead of overflowing the stack;
7124 /// nesting just inside the guard still parses. Both on the stack the
7125 /// callers provide, in debug as in release — the guard's headroom is
7126 /// what this pins.
7127 #[test]
7128 fn gh986_deep_nesting_is_a_clean_error_past_the_guard_and_parses_inside_it() {
7129 on_big_stack(|| {
7130 let deep = parse_nl_text(&deep_sum_nl(4, 30_000));
7131 let msg = deep.err().expect("past the guard must be an Err");
7132 assert!(msg.contains("levels"), "{msg}");
7133 // Just inside the limit this thread's declared stack gives
7134 // (`MAX_PARSE_DEPTH` in an optimised build, 32 768 in an
7135 // unoptimised one, whose worst-case frame is 32 KiB).
7136 let wraps = (parse_depth_limit() as usize - 100) / 2;
7137 assert!(parse_nl_text(&deep_sum_nl(4, wraps)).is_ok());
7138 });
7139 }
7140
7141 use super::*;
7142
7143 /// Compile-time guarantee for the batched-solve path (pounce#126):
7144 /// a parsed problem and the TNLP built from it must be movable to a
7145 /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
7146 /// `!Send` state) into the `Expr` DAG / tape pipeline.
7147 #[test]
7148 fn nl_problem_and_tnlp_are_send() {
7149 fn assert_send<T: Send>() {}
7150 assert_send::<NlProblem>();
7151 assert_send::<NlTnlp>();
7152 assert_send::<Expr>();
7153 }
7154
7155 /// `variant()` patches starting point / bounds on a clone and
7156 /// validates override lengths; the base instance is untouched.
7157 #[test]
7158 fn variant_overrides_bounds_and_x0() {
7159 let p = parse_nl_text(SIMPLE).expect("parse");
7160 let base = NlTnlp::new(p);
7161 let var = base
7162 .variant(&NlVariation {
7163 x0: Some(vec![3.0, 4.0]),
7164 x_l: Some(vec![-1.0, -2.0]),
7165 x_u: Some(vec![5.0, 6.0]),
7166 ..Default::default()
7167 })
7168 .expect("variant");
7169 let mut var = var;
7170 let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
7171 let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
7172 assert!(var.get_bounds_info(BoundsInfo {
7173 x_l: &mut x_l,
7174 x_u: &mut x_u,
7175 g_l: &mut g_l,
7176 g_u: &mut g_u,
7177 }));
7178 assert_eq!(x_l, [-1.0, -2.0]);
7179 assert_eq!(x_u, [5.0, 6.0]);
7180 let mut x = [0.0; 2];
7181 let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
7182 assert!(var.get_starting_point(StartingPoint {
7183 init_x: true,
7184 x: &mut x,
7185 init_z: false,
7186 z_l: &mut zl,
7187 z_u: &mut zu,
7188 init_lambda: false,
7189 lambda: &mut lam,
7190 }));
7191 assert_eq!(x, [3.0, 4.0]);
7192 // Base keeps its parsed (free) bounds.
7193 assert!(base.problem().x_l[0] < -1.0e18);
7194 // Length mismatch is an error, not a panic.
7195 assert!(
7196 base.variant(&NlVariation {
7197 x0: Some(vec![1.0]),
7198 ..Default::default()
7199 })
7200 .is_err()
7201 );
7202 }
7203
7204 /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
7205 /// Header values:
7206 /// line 2: n=2 m=0 num_obj=1 0 0
7207 /// line 3: 0 1 (1 nonlinear objective)
7208 /// line 4: 0 0
7209 /// line 5: 0 2 0 (nonlinear vars in obj=2)
7210 /// line 6: 0 0 0 1
7211 /// line 7: 0 0 0 0 0
7212 /// line 8: 0 0 (no Jacobian nonzeros, no linear obj)
7213 /// line 9: 0 0
7214 /// line 10: 0 0 0 0 0
7215 /// Then `O0 0` followed by an expression tree:
7216 /// `(x0 - 1)^2 + (x1 - 2)^2` =
7217 /// o0
7218 /// o5 (o1 v0 n1) n2
7219 /// o5 (o1 v1 n2) n2
7220 /// Then `b` segment: free for both.
7221 /// gh#987: Gay's ordering places nonlinear integers at the END of each
7222 /// nonlinear block and linear ones at the end of the model.
7223 #[test]
7224 fn discrete_census_locates_nonlinear_and_linear_integer_columns() {
7225 // n = 10: nlvb = 2 (1 int), con-only 3 (1 int), obj-only 1 (1 int),
7226 // then linear, ending in nbv = 1, niv = 1.
7227 let d = DiscreteCensus {
7228 binary: 1,
7229 integer: 1,
7230 nl_both: 1,
7231 nl_cons: 1,
7232 nl_objs: 1,
7233 };
7234 let c = NlCounts {
7235 nl_cons: 1,
7236 nl_objs: 1,
7237 nl_vars_cons: 5,
7238 nl_vars_objs: 6,
7239 nl_vars_both: 2,
7240 };
7241 assert_eq!(d.total(), 5);
7242 assert_eq!(d.integer_columns(10, Some(c)), Some(vec![1, 4, 5, 8, 9]));
7243 // Without the line-5 census the nonlinear columns are unknowable.
7244 assert_eq!(d.integer_columns(10, None), None);
7245 // Linear-only integers need no census.
7246 let lin = DiscreteCensus {
7247 binary: 2,
7248 ..Default::default()
7249 };
7250 assert_eq!(lin.integer_columns(4, None), Some(vec![2, 3]));
7251 // Inconsistent counts: more nonlinear-in-both integers than columns.
7252 let bad = DiscreteCensus {
7253 nl_both: 3,
7254 ..Default::default()
7255 };
7256 assert_eq!(bad.integer_columns(10, Some(c)), None);
7257 }
7258
7259 const SIMPLE: &str = "g3 0 1 0
72602 0 1 0 0
72610 1
72620 0
72630 2 0
72640 0 0 1
72650 0 0 0 0
72660 0
72670 0
72680 0 0 0 0
7269O0 0
7270o0
7271o5
7272o1
7273v0
7274n1
7275n2
7276o5
7277o1
7278v1
7279n2
7280n2
7281b
72823
72833
7284";
7285
7286 #[test]
7287 fn parses_simple_quadratic() {
7288 let p = parse_nl_text(SIMPLE).expect("parse");
7289 assert_eq!(p.n, 2);
7290 assert_eq!(p.m, 0);
7291 assert_eq!(p.num_obj, 1);
7292 // f(0,0) = 1 + 4 = 5
7293 let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
7294 assert!((f - 5.0).abs() < 1e-12);
7295 // f(1,2) = 0
7296 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7297 assert!(f.abs() < 1e-12);
7298 }
7299
7300 #[test]
7301 fn gradient_matches_analytic() {
7302 let p = parse_nl_text(SIMPLE).expect("parse");
7303 let x = [0.5, 1.0];
7304 let mut g = [0.0_f64; 2];
7305 grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
7306 // d/dx0 = 2*(x0-1) = -1.0
7307 // d/dx1 = 2*(x1-2) = -2.0
7308 assert!((g[0] - (-1.0)).abs() < 1e-12);
7309 assert!((g[1] - (-2.0)).abs() < 1e-12);
7310 }
7311
7312 /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
7313 /// with global semantics so the presolve auxiliary-elimination
7314 /// safeguard actually engages. Pre-fix the default trait stub returned
7315 /// `false` and left the slice untouched, so a variable that is
7316 /// nonlinear in the objective but zero-gradient at the probe point
7317 /// could be wrongly eliminated.
7318 ///
7319 /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
7320 /// of the objective (NonLinear); x1 appears only in the linear part
7321 /// (Linear).
7322 #[test]
7323 fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
7324 // (x0 - 1)^2
7325 let obj_nl = Expr::Binary(
7326 BinOp::Pow,
7327 Box::new(Expr::Binary(
7328 BinOp::Sub,
7329 Box::new(Expr::Var(0)),
7330 Box::new(Expr::Const(1.0)),
7331 )),
7332 Box::new(Expr::Const(2.0)),
7333 );
7334 let prob = NlProblem {
7335 src: None,
7336 cse_bodies: Vec::new(),
7337 n: 2,
7338 m: 0,
7339 num_obj: 1,
7340 minimize: true,
7341 obj_nonlinear: NlBody::Tree(obj_nl),
7342 obj_linear: vec![(1, 3.0)],
7343 obj_constant: 0.0,
7344 con_nonlinear: vec![],
7345 con_linear: vec![],
7346 x_l: vec![f64::NEG_INFINITY; 2],
7347 x_u: vec![f64::INFINITY; 2],
7348 g_l: vec![],
7349 g_u: vec![],
7350 x0: vec![0.0; 2],
7351 lambda0: vec![],
7352 suffixes: NlSuffixes::default(),
7353 imported_funcs: vec![],
7354 ampl_options: vec![],
7355 nl_counts: None,
7356 n_discrete: None,
7357 var_names: vec![],
7358 con_names: vec![],
7359 };
7360 let mut tnlp = NlTnlp::new(prob);
7361 let mut types = vec![Linearity::Linear; 2];
7362 let ok = tnlp.get_variables_linearity(&mut types);
7363 // Pre-fix: default stub returns false (slice untouched).
7364 assert!(
7365 ok,
7366 "get_variables_linearity must report it filled the slice"
7367 );
7368 assert!(
7369 matches!(types[0], Linearity::NonLinear),
7370 "x0 is nonlinear in the objective"
7371 );
7372 assert!(
7373 matches!(types[1], Linearity::Linear),
7374 "x1 appears only in the linear part"
7375 );
7376 }
7377
7378 /// Objective-scoped linearity must NOT inherit constraint
7379 /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
7380 /// (constraint tape) but linear w.r.t. the objective, so the presolve
7381 /// H11 guard must not treat it as objective-coupled — that was the CI
7382 /// regression where every gas-network variable (nonlinear in the flow
7383 /// equations, absent from the linear objective) blocked Phase-0
7384 /// elimination.
7385 #[test]
7386 fn objective_variables_linearity_ignores_constraint_nonlinearity() {
7387 // x0^2
7388 let con_nl = Expr::Binary(
7389 BinOp::Pow,
7390 Box::new(Expr::Var(0)),
7391 Box::new(Expr::Const(2.0)),
7392 );
7393 let prob = NlProblem {
7394 src: None,
7395 cse_bodies: Vec::new(),
7396 n: 2,
7397 m: 1,
7398 num_obj: 1,
7399 minimize: true,
7400 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
7401 obj_linear: vec![(1, 3.0)],
7402 obj_constant: 0.0,
7403 con_nonlinear: vec![NlBody::Tree(con_nl)],
7404 con_linear: vec![vec![]],
7405 x_l: vec![f64::NEG_INFINITY; 2],
7406 x_u: vec![f64::INFINITY; 2],
7407 g_l: vec![4.0],
7408 g_u: vec![4.0],
7409 x0: vec![0.0; 2],
7410 lambda0: vec![0.0],
7411 suffixes: NlSuffixes::default(),
7412 imported_funcs: vec![],
7413 ampl_options: vec![],
7414 nl_counts: None,
7415 n_discrete: None,
7416 var_names: vec![],
7417 con_names: vec![],
7418 };
7419 let mut tnlp = NlTnlp::new(prob);
7420
7421 let mut global = vec![Linearity::Linear; 2];
7422 assert!(tnlp.get_variables_linearity(&mut global));
7423 assert!(
7424 matches!(global[0], Linearity::NonLinear),
7425 "global tags see x0's constraint nonlinearity"
7426 );
7427
7428 let mut obj = vec![Linearity::NonLinear; 2];
7429 assert!(tnlp.get_objective_variables_linearity(&mut obj));
7430 assert!(
7431 matches!(obj[0], Linearity::Linear),
7432 "x0 is linear w.r.t. the objective despite the nonlinear constraint"
7433 );
7434 assert!(
7435 matches!(obj[1], Linearity::Linear),
7436 "x1 is linear everywhere"
7437 );
7438 }
7439
7440 /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
7441 /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
7442 /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
7443 /// constraints, and both variables nonlinear in the objective only.
7444 #[test]
7445 fn header_census_is_parsed() {
7446 let p = parse_nl_text(SIMPLE).expect("parse");
7447 let c = p.nl_counts.expect("SIMPLE has a well-formed header");
7448 assert_eq!(c.nl_cons, 0);
7449 assert_eq!(c.nl_objs, 1);
7450 assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
7451 assert_eq!(c.nonlinear_vars(), 2);
7452 }
7453
7454 /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
7455 /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
7456 /// as the overlapping one: disjoint sets add, and `max` would be wrong
7457 /// for them.
7458 #[test]
7459 fn nonlinear_var_total_uses_inclusion_exclusion() {
7460 let c = |vc, vo, vb| NlCounts {
7461 nl_cons: 0,
7462 nl_objs: 0,
7463 nl_vars_cons: vc,
7464 nl_vars_objs: vo,
7465 nl_vars_both: vb,
7466 };
7467 // Fully shared: the same 5 variables in both.
7468 assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
7469 // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
7470 // not the `max(nlvc, nlvo) = 1` a naive reading gives.
7471 assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
7472 // Partial overlap: 4 + 3 - 2.
7473 assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
7474 // Nonsense header: saturates instead of underflowing.
7475 assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
7476 }
7477
7478 /// A header that does not carry the documented fields records no
7479 /// census at all rather than a guess of zero — "no nonlinear
7480 /// variables" is a claim, and a truncated header has not made it.
7481 #[test]
7482 fn short_header_line_records_no_census() {
7483 // Line 5 with two fields instead of `nlvc nlvo nlvb`.
7484 let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
7485 assert_ne!(txt, SIMPLE, "the substitution must have applied");
7486 let p = parse_nl_text(&txt).expect("parse");
7487 assert!(p.nl_counts.is_none());
7488 }
7489
7490 /// `get_number_of_nonlinear_variables` used to be the trait default,
7491 /// `-1` ("assume everything is nonlinear"). It now answers from the
7492 /// trees, and `get_list_of_nonlinear_variables` agrees with it.
7493 ///
7494 /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
7495 #[test]
7496 fn nonlinear_variable_list_excludes_linear_columns() {
7497 let obj_nl = Expr::Binary(
7498 BinOp::Pow,
7499 Box::new(Expr::Binary(
7500 BinOp::Sub,
7501 Box::new(Expr::Var(0)),
7502 Box::new(Expr::Const(1.0)),
7503 )),
7504 Box::new(Expr::Const(2.0)),
7505 );
7506 let parts = NlProblemParts {
7507 minimize: true,
7508 objective: obj_nl,
7509 obj_constant: 0.0,
7510 constraints: vec![],
7511 x_l: vec![-1e19; 2],
7512 x_u: vec![1e19; 2],
7513 x0: vec![0.0; 2],
7514 g_l: vec![],
7515 g_u: vec![],
7516 var_names: vec![],
7517 con_names: vec![],
7518 };
7519 let prob = NlProblem::from_expressions(parts).expect("build");
7520 assert!(
7521 prob.nl_counts.is_none(),
7522 "a model built in memory has no header to read"
7523 );
7524 let mut tnlp = NlTnlp::new(prob);
7525 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
7526 let mut list = [-1 as Index; 2];
7527 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7528 assert_eq!(list[0], 0);
7529 }
7530
7531 /// When the header says every variable is nonlinear the walk is
7532 /// skipped, and the answer is the same one the walk would give for
7533 /// `SIMPLE` (both variables appear in the objective's nonlinear part).
7534 #[test]
7535 fn all_nonlinear_header_short_circuits_to_n() {
7536 let p = parse_nl_text(SIMPLE).expect("parse");
7537 assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
7538 let mut tnlp = NlTnlp::new(p);
7539 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
7540 let mut list = [-1 as Index; 2];
7541 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
7542 assert_eq!(list, [0, 1]);
7543 }
7544
7545 /// The list must not be written when the caller's slice is too small —
7546 /// the contract's `false` return, not a panic.
7547 #[test]
7548 fn nonlinear_variable_list_declines_a_short_slice() {
7549 let p = parse_nl_text(SIMPLE).expect("parse");
7550 let mut tnlp = NlTnlp::new(p);
7551 let mut list = [-1 as Index; 1];
7552 assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
7553 assert_eq!(list, [-1]);
7554 }
7555
7556 /// `min x0^2 + x1^2 s.t. x0 + x1 = 1`.
7557 /// One equality constraint with a purely linear Jacobian — exercises
7558 /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
7559 /// kind 4).
7560 ///
7561 /// Header layout:
7562 /// line 1: g3 0 1 0
7563 /// line 2: 2 1 1 0 0 (n=2, m=1, num_obj=1)
7564 /// line 3: 0 1 (1 nonlinear obj, 0 nonlinear cons)
7565 /// line 4: 0 0
7566 /// line 5: 0 2 0 (nonlinear vars in obj=2)
7567 /// line 6: 0 0 0 1
7568 /// line 7: 0 0 0 0 0
7569 /// line 8: 2 0 (Jacobian nnz=2, no linear obj)
7570 /// line 9: 0 0
7571 /// line 10: 0 0 0 0 0
7572 /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
7573 /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
7574 const EQ_LIN: &str = "g3 0 1 0
75752 1 1 0 0
75760 1
75770 0
75780 2 0
75790 0 0 1
75800 0 0 0 0
75812 0
75820 0
75830 0 0 0 0
7584C0
7585n0
7586O0 0
7587o0
7588o5
7589v0
7590n2
7591o5
7592v1
7593n2
7594r
75954 1
7596b
75973
75983
7599k1
76002
7601J0 2
76020 1
76031 1
7604";
7605
7606 #[test]
7607 fn parses_constrained_problem() {
7608 let p = parse_nl_text(EQ_LIN).expect("parse");
7609 assert_eq!(p.n, 2);
7610 assert_eq!(p.m, 1);
7611 // r-segment kind 4 (equality with rhs=1).
7612 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7613 assert!((p.g_u[0] - 1.0).abs() < 1e-12);
7614 // J-row 0: x0 (coef 1), x1 (coef 1).
7615 assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
7616 }
7617
7618 #[test]
7619 fn malformed_j_variable_index_is_parse_error_not_panic() {
7620 // Code review L32: a J-segment entry's variable (column) index was
7621 // pushed into con_linear unchecked, so an out-of-range index (here 5
7622 // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
7623 // constraint evaluation. It must instead surface as a clean parse
7624 // error, consistent with the existing `J<row> out of range` check.
7625 let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
7626 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7627 let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
7628 assert!(err.contains("out of range"), "unexpected error: {err}");
7629 }
7630
7631 #[test]
7632 fn out_of_range_x_segment_index_is_parse_error() {
7633 // Same strictness for the initial-primal `x` segment: an index past
7634 // `n` used to be silently dropped; now it is a parse error, so the
7635 // four index-bearing segments (J/G/x/d) behave consistently.
7636 let bad = format!("{EQ_LIN}x1\n5 0.5\n");
7637 let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
7638 assert!(err.contains("out of range"), "unexpected error: {err}");
7639 }
7640
7641 // ---------------------------------------------------------------
7642 // gh #492 — a constant `C<i>` body folds into the row bounds.
7643 //
7644 // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
7645 // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
7646 // constant-body rows to fold.
7647 // ---------------------------------------------------------------
7648
7649 /// Linearity is a *linearity* test, not "did a `C` segment touch this
7650 /// row". A row whose body is the bare constant `3` is affine.
7651 #[test]
7652 fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
7653 // `x0 + x1 + 3 = 1` ⇔ `x0 + x1 = -2`.
7654 let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7655 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7656 let p = parse_nl_text(&nl).expect("parse");
7657
7658 assert!(
7659 p.con_nonlinear[0].is_trivially_zero(),
7660 "the constant body must be replaced by the identity zero, got {:?}",
7661 p.con_nonlinear[0]
7662 );
7663 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7664 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7665
7666 let mut lin = [Linearity::NonLinear];
7667 let mut t = NlTnlp::new(p);
7668 assert!(t.get_constraints_linearity(&mut lin));
7669 assert_eq!(lin[0], Linearity::Linear);
7670 }
7671
7672 /// The shift must be exact, not merely "linear now": the folded model
7673 /// and the hand-folded one must be the same problem, row body for row
7674 /// body and bound for bound. That is what keeps feasibility, the
7675 /// active set, and the duals unchanged.
7676 #[test]
7677 fn folding_a_row_constant_gives_the_hand_folded_problem() {
7678 // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
7679 let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
7680 let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
7681 assert_ne!(offset, EQ_LIN);
7682 assert_ne!(folded, EQ_LIN);
7683
7684 let a = parse_nl_text(&offset).expect("parse offset form");
7685 let b = parse_nl_text(&folded).expect("parse folded form");
7686 assert_eq!(a.g_l, b.g_l);
7687 assert_eq!(a.g_u, b.g_u);
7688 assert_eq!(a.con_linear, b.con_linear);
7689
7690 // And the row *values* agree pointwise, which is the property the
7691 // duals ride on: `g(x) - g_l` is the same residual either way.
7692 let mut ga = [0.0];
7693 let mut gb = [0.0];
7694 let x = [0.75, -1.25];
7695 assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
7696 assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
7697 assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
7698 }
7699
7700 /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
7701 /// constant as `n3`, and AMPL emits such trees when a expression
7702 /// collapses without being re-simplified.
7703 #[test]
7704 fn a_row_body_that_evaluates_to_a_constant_folds_too() {
7705 // `C0` = `1 + 2`.
7706 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
7707 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7708 let p = parse_nl_text(&nl).expect("parse");
7709 assert!(p.con_nonlinear[0].is_trivially_zero());
7710 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
7711 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
7712 }
7713
7714 /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
7715 /// "absent", not "a very large number". Shifting one turns it into a
7716 /// real bound and invents a constraint that is not in the model.
7717 ///
7718 /// The constants here are deliberately huge. An everyday `3` is
7719 /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
7720 /// presence guard would go unnoticed at ordinary magnitudes and then
7721 /// bite on a model that scales its rows. The guard is what makes the
7722 /// sentinel untouchable at *any* magnitude.
7723 #[test]
7724 fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
7725 // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
7726 // bound. Shifting the lower sentinel would leave `-9e18`, a real
7727 // bound, so the row would gain a floor the model never stated.
7728 let nl = EQ_LIN
7729 .replace("C0\nn0\n", "C0\nn-1e18\n")
7730 .replace("r\n4 1\n", "r\n1 1\n");
7731 let p = parse_nl_text(&nl).expect("parse");
7732 assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
7733 assert!(
7734 !lower_bound_present(p.g_l[0]),
7735 "the absent-lower sentinel became a real bound: {}",
7736 p.g_l[0]
7737 );
7738
7739 // The mirror case: a positive constant on a lower-bounded row is
7740 // the one that would pull the *upper* sentinel below 1e19.
7741 let nl = EQ_LIN
7742 .replace("C0\nn0\n", "C0\nn1e18\n")
7743 .replace("r\n4 1\n", "r\n2 1\n");
7744 let p = parse_nl_text(&nl).expect("parse");
7745 assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
7746 assert!(
7747 !upper_bound_present(p.g_u[0]),
7748 "the absent-upper sentinel became a real bound: {}",
7749 p.g_u[0]
7750 );
7751 }
7752
7753 /// A row body that mentions a variable is not a constant, however
7754 /// simple it looks. Folding it would delete the term.
7755 #[test]
7756 fn a_row_body_with_a_variable_is_not_folded() {
7757 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
7758 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7759 let p = parse_nl_text(&nl).expect("parse");
7760 assert!(
7761 !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
7762 "a row in x0 was folded away: {:?}",
7763 p.con_nonlinear[0]
7764 );
7765 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
7766 assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
7767 }
7768
7769 /// A variable-free body whose value is not finite is left in place. It
7770 /// makes the row infeasible (or ill-posed) and that is the solver's
7771 /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
7772 /// corrupt the bound pair and take every downstream presence test with
7773 /// it.
7774 #[test]
7775 fn a_non_finite_constant_row_body_is_not_folded() {
7776 // `C0` = `log(-1)` = NaN.
7777 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
7778 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7779 let p = parse_nl_text(&nl).expect("parse");
7780 assert!(
7781 !p.con_nonlinear[0].is_trivially_zero(),
7782 "a NaN body was folded into the bounds"
7783 );
7784 assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
7785 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
7786 }
7787
7788 /// An imported-function call is not a parse-time constant even with
7789 /// constant arguments — it is resolved to a shared library much later
7790 /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
7791 /// rather than guess. The fold must decline before it evaluates.
7792 #[test]
7793 fn a_constant_argument_funcall_row_body_is_not_folded() {
7794 // Declare one imported function and call it with a literal.
7795 let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
7796 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
7797 let p = parse_nl_text(&nl).expect("parse");
7798 assert!(
7799 matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
7800 "expected the funcall to survive the fold, got {:?}",
7801 p.con_nonlinear[0]
7802 );
7803 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
7804 }
7805
7806 #[test]
7807 fn k_segment_nonstandard_count_is_parse_error_at_source() {
7808 // Code review L35: the `k` (Jacobian column-count) segment header
7809 // declares how many count lines follow — `k<count>` — and the
7810 // standard value is n-1. The parser used to *assume* n-1 and ignore
7811 // the header, so a file declaring a different count read the wrong
7812 // number of data lines, desynced the segment stream, and failed far
7813 // downstream with a confusing error (or silently mis-parsed). With
7814 // the declared count now read and validated, a nonstandard count is
7815 // a clear parse error at its source. Here EQ_LIN has n=2 (expected
7816 // count 1); rewrite its `k1` + one count line to `k0`.
7817 let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
7818 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
7819 let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
7820 assert!(
7821 err.contains("k-segment declares"),
7822 "expected a clear k-segment count error, got: {err}"
7823 );
7824 }
7825
7826 #[test]
7827 fn get_starting_point_returns_nl_initial_duals() {
7828 // Code review 2026-06 item M19: the `.nl` `d` segment supplies
7829 // initial constraint multipliers. They are parsed into `lambda0`,
7830 // but `get_starting_point` previously ignored them — so a
7831 // `warm_start_init_point yes` solve silently began from zero duals.
7832 // `get_starting_point` must hand the parsed duals back when the
7833 // engine requests them (`init_lambda`), and leave the buffer
7834 // untouched when it does not.
7835 let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
7836 let p = parse_nl_text(&nl).expect("parse");
7837 // gh#1033: a `d` entry is an AMPL dual, so it is stored negated.
7838 assert_eq!(p.lambda0, vec![-2.5], "the `d` segment fills lambda0");
7839
7840 let mut t = NlTnlp::new(p);
7841 let info = t.get_nlp_info().unwrap();
7842 let (n, m) = (info.n as usize, info.m as usize);
7843
7844 // Warm-start request: init_lambda = true → the parsed `.nl` duals
7845 // must be returned (pre-fix this stayed zero).
7846 let mut x = vec![0.0; n];
7847 let mut z_l = vec![0.0; n];
7848 let mut z_u = vec![0.0; n];
7849 let mut lambda = vec![0.0; m];
7850 assert!(t.get_starting_point(StartingPoint {
7851 init_x: true,
7852 x: &mut x,
7853 init_z: false,
7854 z_l: &mut z_l,
7855 z_u: &mut z_u,
7856 init_lambda: true,
7857 lambda: &mut lambda,
7858 }));
7859 assert_eq!(
7860 lambda,
7861 vec![-2.5],
7862 "a warm start must use the `.nl` initial duals, not zero"
7863 );
7864
7865 // No warm-start request: the multiplier buffer is left alone (the
7866 // engine owns its default), so honoring the flag does not clobber it.
7867 let mut lambda_untouched = vec![7.0; m];
7868 assert!(t.get_starting_point(StartingPoint {
7869 init_x: true,
7870 x: &mut x,
7871 init_z: false,
7872 z_l: &mut z_l,
7873 z_u: &mut z_u,
7874 init_lambda: false,
7875 lambda: &mut lambda_untouched,
7876 }));
7877 assert_eq!(
7878 lambda_untouched,
7879 vec![7.0],
7880 "without init_lambda the multiplier buffer must be untouched"
7881 );
7882 }
7883
7884 /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
7885 /// unconstrained, `n` variables. The trailing product puts a nonzero
7886 /// in row 0 of *every* Hessian column, which is the shape that used
7887 /// to force the greedy coloring to hand out one color per variable.
7888 fn dense_row_objective_nl(n: usize) -> String {
7889 let mut s = String::new();
7890 s.push_str("g3 1 1 0\n");
7891 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
7892 s.push_str(" 0 1\n 0 0\n");
7893 s.push_str(&format!(" {n} {n} {n}\n"));
7894 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
7895 s.push_str(&format!(" 0 {n}\n"));
7896 s.push_str(" 0 0\n 0 0 0 0 0\n");
7897 // objective
7898 s.push_str("O0 0\n");
7899 s.push_str(&format!("o54\n{}\n", n + 1));
7900 for j in 0..n {
7901 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
7902 }
7903 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
7904 for j in 0..n {
7905 s.push_str(&format!("v{j}\n"));
7906 }
7907 // start, bounds, gradient
7908 s.push_str(&format!("x{n}\n"));
7909 for j in 0..n {
7910 s.push_str(&format!("{j} 0.5\n"));
7911 }
7912 s.push_str("b\n");
7913 for _ in 0..n {
7914 s.push_str("3\n");
7915 }
7916 s.push_str(&format!("G0 {n}\n"));
7917 for j in 0..n {
7918 s.push_str(&format!("{j} 0.0\n"));
7919 }
7920 s
7921 }
7922
7923 /// Same shape as [`dense_row_objective_nl`], but the coupling term
7924 /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
7925 /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
7926 /// magnitude makes the dense column ill-scaled — the case where
7927 /// reading its entries out of its own pass costs real digits.
7928 fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
7929 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
7930 let mut s = String::new();
7931 s.push_str("g3 1 1 0\n");
7932 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
7933 s.push_str(" 0 1\n 0 0\n");
7934 s.push_str(&format!(" {n} {n} {n}\n"));
7935 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
7936 s.push_str(&format!(" 0 {n}\n"));
7937 s.push_str(" 0 0\n 0 0 0 0 0\n");
7938 s.push_str("O0 0\n");
7939 s.push_str(&format!("o54\n{}\n", n + 1));
7940 for j in 0..n {
7941 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
7942 }
7943 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
7944 for j in 0..n {
7945 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
7946 }
7947 s.push_str(&format!("x{n}\n"));
7948 for j in 0..n {
7949 s.push_str(&format!("{j} 0.5\n"));
7950 }
7951 s.push_str("b\n");
7952 for _ in 0..n {
7953 s.push_str("3\n");
7954 }
7955 s.push_str(&format!("G0 {n}\n"));
7956 for j in 0..n {
7957 s.push_str(&format!("{j} 0.0\n"));
7958 }
7959 s
7960 }
7961
7962 /// Locate a model in the benchmark corpus, or `None` if the corpus is
7963 /// not on this machine.
7964 ///
7965 /// The corpus is ~2 GB and deliberately outside the checkout (see
7966 /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
7967 /// no-op rather than fail. That is a real limitation — a check that
7968 /// silently does nothing is how the gap below went unnoticed in the
7969 /// first place — so anything using this must also be covered by a
7970 /// synthetic case that always runs.
7971 fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
7972 let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
7973 let p = std::path::PathBuf::from(root).join(rel);
7974 p.is_file().then_some(p)
7975 }
7976
7977 /// The corpus check the dense-column optimization never got.
7978 ///
7979 /// `cho_parmest` is the model whose certificate the optimization cost,
7980 /// and it could not have caught it: the model is 4.3 MB and lives in
7981 /// the benchmark data set, not in the repository, so validating
7982 /// against the in-repo `.nl` fixtures said nothing about it. This test
7983 /// closes that by checking the corpus directly wherever the corpus
7984 /// exists, which is every machine and CI job that runs the benchmarks.
7985 ///
7986 /// The assertion is the one that matters and the one that was never
7987 /// made: whatever the guard leaves peeled must decode to the same
7988 /// Hessian as peeling nothing. Against the pre-fix code this fails —
7989 /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
7990 #[test]
7991 fn cho_parmest_decodes_to_its_unpeeled_reference() {
7992 let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
7993 eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
7994 return;
7995 };
7996 let p = read_nl_file(&path).expect("read cho_parmest");
7997 let n = p.n;
7998 let mut t = NlTnlp::new(p);
7999 // The guard vetoes 7 of cho's 12 peeled columns, and putting those
8000 // seven dense rows back into the conflict graph costs the coloring
8001 // outright: it goes from 17 colors to 9010, and no column clears the
8002 // density threshold afterwards, so the model ends up fully unpeeled.
8003 // That is the price of the certificate on this model, and it is worth
8004 // knowing rather than assuming the other five survive.
8005 assert!(t.peeled_cols.is_empty());
8006
8007 let info = t.get_nlp_info().unwrap();
8008 let nnz = info.nnz_h_lag as usize;
8009 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8010 assert!(t.eval_h(
8011 None,
8012 true,
8013 1.0,
8014 None,
8015 true,
8016 SparsityRequest::Structure {
8017 irow: &mut irow,
8018 jcol: &mut jcol
8019 }
8020 ));
8021 // Evaluate away from `x0` with non-uniform multipliers. The damage
8022 // is point-dependent, and `x0` is close to where it is least
8023 // visible: only 5 entries move there, against 48,931 here. Note the
8024 // guard's own probe runs at `x0` — it fires anyway because it tests
8025 // a bound on what a pass *could* lose, not the damage it happens to
8026 // commit at one point. That is the property that makes it robust,
8027 // and this asymmetry is worth keeping in front of anyone who
8028 // retunes it.
8029 let x: Vec<f64> = t
8030 .prob
8031 .x0
8032 .iter()
8033 .enumerate()
8034 .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
8035 .collect();
8036 let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
8037
8038 let mut got = vec![0.0_f64; nnz];
8039 assert!(t.eval_h(
8040 Some(&x),
8041 true,
8042 1.0,
8043 Some(&lambda),
8044 true,
8045 SparsityRequest::Values { values: &mut got }
8046 ));
8047
8048 t.recolor(&vec![true; n]);
8049 assert!(t.peeled_cols.is_empty());
8050 let mut want = vec![0.0_f64; nnz];
8051 assert!(t.eval_h(
8052 Some(&x),
8053 true,
8054 1.0,
8055 Some(&lambda),
8056 true,
8057 SparsityRequest::Values { values: &mut want }
8058 ));
8059
8060 let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
8061 let mut worst = 0.0_f64;
8062 let mut at = 0usize;
8063 for k in 0..nnz {
8064 let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8065 if rel > worst {
8066 worst = rel;
8067 at = k;
8068 }
8069 }
8070 assert!(
8071 worst <= 1e-13,
8072 "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
8073 {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
8074 out of a pass it cannot be read out of.",
8075 irow[at],
8076 jcol[at],
8077 got[at],
8078 want[at]
8079 );
8080
8081 // The check above passes trivially on correct code, because the
8082 // guard leaves cho fully unpeeled and so compares a configuration
8083 // against itself. It only has teeth against a regression. So prove
8084 // the guard is doing necessary work rather than assuming it: put
8085 // the peels back the way the pre-fix reader had them and confirm
8086 // the Hessian really does come apart.
8087 t.recolor(&vec![false; n]);
8088 assert!(
8089 !t.peeled_cols.is_empty(),
8090 "restoring the unvetoed coloring must peel again"
8091 );
8092 let mut unguarded = vec![0.0_f64; nnz];
8093 assert!(t.eval_h(
8094 Some(&x),
8095 true,
8096 1.0,
8097 Some(&lambda),
8098 true,
8099 SparsityRequest::Values {
8100 values: &mut unguarded
8101 }
8102 ));
8103 let mut bad = 0usize;
8104 let mut worst_unguarded = 0.0_f64;
8105 for k in 0..nnz {
8106 let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
8107 if rel > 1e-13 {
8108 bad += 1;
8109 }
8110 worst_unguarded = worst_unguarded.max(rel);
8111 }
8112 // Two different counts get quoted about this model and they measure
8113 // different things: 48,931 of the 96,000 entries differ from the
8114 // uncompressed reference *at all*, down to the last bit, while 88
8115 // exceed 1e-13 relative. The second is the one worth asserting on.
8116 assert!(
8117 bad >= 50 && worst_unguarded > 1e-11,
8118 "peeling cho_parmest unguarded should damage the entries the guard \
8119 exists to protect (measured: 88 entries past 1e-13 relative, \
8120 worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
8121 this fires, the model or the corpus changed and the guard's \
8122 calibration should be re-derived rather than the bound relaxed."
8123 );
8124 eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
8125 }
8126
8127 /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
8128 /// the dense variable is `x_{n-1}` instead of `x_0`:
8129 /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
8130 ///
8131 /// The index matters, and it is the whole reason this helper exists.
8132 /// With the dense variable at 0 every coupling entry is stored as
8133 /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
8134 /// out of column 0's own pass whether or not peeling is on, so the two
8135 /// paths are bit-identical and no test built on that shape can tell
8136 /// them apart. Putting the dense variable last stores them as
8137 /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
8138 /// back from column `n-1`'s pass by symmetry, carrying that pass's
8139 /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
8140 /// That is the category every one of `cho_parmest`'s 48,931 damaged
8141 /// entries fell into.
8142 fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
8143 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
8144 let d = n - 1;
8145 let mut s = String::new();
8146 s.push_str("g3 1 1 0\n");
8147 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
8148 s.push_str(" 0 1\n 0 0\n");
8149 s.push_str(&format!(" {n} {n} {n}\n"));
8150 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8151 s.push_str(&format!(" 0 {n}\n"));
8152 s.push_str(" 0 0\n 0 0 0 0 0\n");
8153 s.push_str("O0 0\n");
8154 s.push_str(&format!("o54\n{}\n", n + 1));
8155 for j in 0..n {
8156 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
8157 }
8158 s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
8159 for j in 0..d {
8160 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
8161 }
8162 s.push_str(&format!("x{n}\n"));
8163 for j in 0..n {
8164 s.push_str(&format!("{j} 0.5\n"));
8165 }
8166 s.push_str("b\n");
8167 for _ in 0..n {
8168 s.push_str("3\n");
8169 }
8170 s.push_str(&format!("G0 {n}\n"));
8171 for j in 0..n {
8172 s.push_str(&format!("{j} 0.0\n"));
8173 }
8174 s
8175 }
8176
8177 /// A well-scaled dense column is still peeled, and the entries read
8178 /// out of its pass are exact — the property the peel guard must not
8179 /// cost us.
8180 #[test]
8181 fn a_well_scaled_dense_column_is_still_peeled() {
8182 let n = 200;
8183 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
8184 let t = NlTnlp::new(p);
8185 assert_eq!(
8186 t.peeled_cols,
8187 vec![0],
8188 "a dense column that costs no accuracy must stay peeled"
8189 );
8190 assert!(
8191 t.seeds.len() <= 4,
8192 "peeling should keep the color count at O(1), got {}",
8193 t.seeds.len()
8194 );
8195 }
8196
8197 /// An ill-scaled dense column must be un-peeled and colored the
8198 /// ordinary way, so its small entries come back to full precision.
8199 ///
8200 /// This test covers the *guard*, not the damage. It asserts that a
8201 /// column spanning 12 orders is un-peeled and that its entries are then
8202 /// exact — and it is worth being precise that it does not, and cannot,
8203 /// show what peeling would have cost, because on this model peeling
8204 /// costs nothing: force the peel through and every entry still comes
8205 /// back bit-identical to its analytic weight. The Hessian here is
8206 /// constant and each entry is a single product, so there is no
8207 /// accumulation for a large-magnitude pass to pollute.
8208 ///
8209 /// Reproducing the actual digit loss takes a model whose entries are
8210 /// summed through shared intermediates, which is why the demonstration
8211 /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
8212 /// real model rather than a synthetic one.
8213 #[test]
8214 fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
8215 let n = 200;
8216 let span = 12.0;
8217 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
8218 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
8219 let mut t = NlTnlp::new(p);
8220
8221 assert!(
8222 t.peeled_cols.is_empty(),
8223 "a dense column spanning {span} orders must not be peeled; got {:?}",
8224 t.peeled_cols
8225 );
8226
8227 let info = t.get_nlp_info().unwrap();
8228 let nnz = info.nnz_h_lag as usize;
8229 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8230 assert!(t.eval_h(
8231 None,
8232 true,
8233 1.0,
8234 None,
8235 true,
8236 SparsityRequest::Structure {
8237 irow: &mut irow,
8238 jcol: &mut jcol
8239 }
8240 ));
8241 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8242 let mut vals = vec![0.0_f64; nnz];
8243 assert!(t.eval_h(
8244 Some(&x),
8245 true,
8246 1.0,
8247 None,
8248 true,
8249 SparsityRequest::Values { values: &mut vals }
8250 ));
8251
8252 // Every coupling entry must be its weight to full relative
8253 // precision. Peeled, the smallest of them came back with a
8254 // relative error near 1e-4.
8255 let mut checked = 0;
8256 for k in 0..nnz {
8257 let (i, j) = (irow[k] as usize, jcol[k] as usize);
8258 if i == j {
8259 continue;
8260 }
8261 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8262 checked += 1;
8263 let want = w(i);
8264 assert!(
8265 (vals[k] - want).abs() <= 1e-13 * want,
8266 "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
8267 vals[k],
8268 (vals[k] - want).abs() / want
8269 );
8270 }
8271 assert_eq!(checked, n - 1);
8272 }
8273
8274 /// Whatever survives the peel guard must decode to the same Hessian
8275 /// that not peeling at all produces — every entry, not just the
8276 /// objective.
8277 ///
8278 /// This exists because the original dense-column optimization was
8279 /// validated by running the repository's `.nl` fixtures and comparing
8280 /// objective value and exit status, which was doubly blind: not one of
8281 /// the 60 fixtures has a Hessian row dense enough to peel anything, so
8282 /// the decode path under test never executed, and even had it executed,
8283 /// the defect cost digits in the multipliers while leaving the
8284 /// objective intact. So the instrument has to be the assembled Hessian
8285 /// and the input has to actually peel — hence `peeled_any` below, which
8286 /// fails if the sweep ever goes vacuous the way the fixture suite
8287 /// silently did.
8288 ///
8289 /// What this catches is a *decode* fault: an entry recovered from the
8290 /// wrong pass, which is wrong by O(1). It would not have caught the
8291 /// `cho_parmest` stall, because these synthetic models lose no
8292 /// precision under peeling at all (see
8293 /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
8294 /// precision half is covered against the real model in
8295 /// `cho_parmest_decodes_to_its_unpeeled_reference`.
8296 #[test]
8297 fn a_peeled_decode_matches_an_unpeeled_reference() {
8298 // `last = true` puts the dense variable at `n-1`, so the coupling
8299 // entries are stored with the peeled column as their *row* — the
8300 // only shape in which peeling and not peeling read an entry out of
8301 // different passes, and so the only shape that can detect a
8302 // difference at all. See `weighted_dense_last_col_nl`.
8303 let cases = [
8304 (200, 0.0, false),
8305 (200, 2.0, false),
8306 (200, 0.0, true),
8307 (200, 1.0, true),
8308 (200, 2.0, true),
8309 (400, 3.0, true),
8310 (600, 0.0, true),
8311 ];
8312 let mut peeled_any = false;
8313 let mut worst = 0.0_f64;
8314
8315 for &(n, span, last) in &cases {
8316 let text = if last {
8317 weighted_dense_last_col_nl(n, span)
8318 } else {
8319 weighted_dense_row_objective_nl(n, span)
8320 };
8321 let p = parse_nl_text(&text).expect("parse");
8322 let mut t = NlTnlp::new(p);
8323 let peeled = t.peeled_cols.clone();
8324 peeled_any |= !peeled.is_empty();
8325
8326 let info = t.get_nlp_info().unwrap();
8327 let nnz = info.nnz_h_lag as usize;
8328 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
8329 assert!(t.eval_h(
8330 None,
8331 true,
8332 1.0,
8333 None,
8334 true,
8335 SparsityRequest::Structure {
8336 irow: &mut irow,
8337 jcol: &mut jcol
8338 }
8339 ));
8340 let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
8341
8342 let mut got = vec![0.0_f64; nnz];
8343 assert!(t.eval_h(
8344 Some(&x),
8345 true,
8346 1.0,
8347 None,
8348 true,
8349 SparsityRequest::Values { values: &mut got }
8350 ));
8351
8352 // Same object, same tapes, same point — only the coloring
8353 // differs, so any disagreement is the decode path.
8354 t.recolor(&vec![true; n]);
8355 assert!(
8356 t.peeled_cols.is_empty(),
8357 "a fully vetoed model must peel nothing"
8358 );
8359 let mut want = vec![0.0_f64; nnz];
8360 assert!(t.eval_h(
8361 Some(&x),
8362 true,
8363 1.0,
8364 None,
8365 true,
8366 SparsityRequest::Values { values: &mut want }
8367 ));
8368
8369 for k in 0..nnz {
8370 let scale = want[k].abs().max(f64::MIN_POSITIVE);
8371 let rel = (got[k] - want[k]).abs() / scale;
8372 worst = worst.max(rel);
8373 assert!(
8374 rel <= 1e-13,
8375 "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
8376 unpeeled reference {:e} (relative error {rel:e})",
8377 irow[k],
8378 jcol[k],
8379 got[k],
8380 want[k]
8381 );
8382 }
8383 }
8384
8385 assert!(
8386 peeled_any,
8387 "no case peeled anything, so this test proved nothing about the \
8388 decode path — the exact way the fixture suite missed the bug"
8389 );
8390 assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
8391 }
8392
8393 /// `peel_veto` bars a column from the peel set, and the row it puts
8394 /// back into the conflict structure costs the colors it used to save.
8395 #[test]
8396 fn a_vetoed_column_is_colored_the_ordinary_way() {
8397 let n = 300;
8398 // One dense row plus a diagonal: column 0 touches every row.
8399 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8400 pairs.extend((1..n).map(|i| (i, 0)));
8401 pairs.sort_unstable();
8402
8403 let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8404 assert!(peeled[0], "the dense column should peel by default");
8405 assert!(
8406 colors_peeled <= 4,
8407 "peeling should collapse the count, got {colors_peeled}"
8408 );
8409
8410 let mut veto = vec![false; n];
8411 veto[0] = true;
8412 let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
8413 assert!(!peeled[0], "a vetoed column must not be peeled");
8414 assert!(
8415 colors_vetoed > colors_peeled,
8416 "un-peeling restores row 0's conflicts, so colors must rise: \
8417 {colors_vetoed} vs {colors_peeled}"
8418 );
8419 }
8420
8421 /// A single dense Hessian row must not blow the coloring up to one
8422 /// color per variable. It used to: every column shares row 0, so no
8423 /// two columns could be colored alike, and `seeds` / `compressed`
8424 /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
8425 /// per `eval_h` on a Hessian holding only ~2n nonzeros.
8426 #[test]
8427 fn a_dense_hessian_row_does_not_explode_the_coloring() {
8428 let n = 200;
8429 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8430 let t = NlTnlp::new(p);
8431 // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
8432 // for j >= 1 from the coupling term.
8433 assert_eq!(t.h_irow.len(), 2 * n - 1);
8434 assert!(
8435 t.seeds.len() <= 4,
8436 "one dense row should cost one extra color, not n; got {} colors for n={n}",
8437 t.seeds.len()
8438 );
8439 assert_eq!(t.seeds.len(), t.compressed.len());
8440 }
8441
8442 /// Peeling changes *which* directional product recovers an entry, so
8443 /// the recovered Hessian must still be exactly right — including the
8444 /// entries read out of a peeled column's pass by symmetry.
8445 #[test]
8446 fn peeled_dense_column_still_recovers_the_exact_hessian() {
8447 let n = 200;
8448 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
8449 let mut t = NlTnlp::new(p);
8450 let info = t.get_nlp_info().unwrap();
8451 let nnz = info.nnz_h_lag as usize;
8452
8453 let mut irow = vec![0_i32; nnz];
8454 let mut jcol = vec![0_i32; nnz];
8455 assert!(t.eval_h(
8456 None,
8457 true,
8458 1.0,
8459 None,
8460 true,
8461 SparsityRequest::Structure {
8462 irow: &mut irow,
8463 jcol: &mut jcol
8464 }
8465 ));
8466
8467 // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
8468 // H[0,0] = 2 + 2 = 4; H[j,j] = 2 (j >= 1); H[j,0] = 1 (j >= 1).
8469 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
8470 let obj_factor = 2.5;
8471 let mut vals = vec![0.0_f64; nnz];
8472 assert!(t.eval_h(
8473 Some(&x),
8474 true,
8475 obj_factor,
8476 None,
8477 true,
8478 SparsityRequest::Values { values: &mut vals }
8479 ));
8480
8481 let mut seen_diag = 0;
8482 let mut seen_coupling = 0;
8483 for k in 0..nnz {
8484 let (i, j) = (irow[k] as usize, jcol[k] as usize);
8485 let want = if i == 0 && j == 0 {
8486 4.0
8487 } else if i == j {
8488 seen_diag += 1;
8489 2.0
8490 } else {
8491 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
8492 seen_coupling += 1;
8493 1.0
8494 } * obj_factor;
8495 assert!(
8496 (vals[k] - want).abs() < 1e-12,
8497 "H[{i},{j}] = {}, want {want}",
8498 vals[k]
8499 );
8500 }
8501 assert_eq!(seen_diag, n - 1);
8502 assert_eq!(seen_coupling, n - 1);
8503 }
8504
8505 /// The peeling threshold must leave ordinary sparse models on
8506 /// exactly the coloring they had before: a banded Hessian colors by
8507 /// its bandwidth, with nothing peeled.
8508 #[test]
8509 fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
8510 let n = 400;
8511 let pairs: Vec<(usize, usize)> = (0..n)
8512 .flat_map(|j| {
8513 let mut v = vec![(j, j)];
8514 if j + 1 < n {
8515 v.push((j + 1, j));
8516 }
8517 v
8518 })
8519 .collect();
8520 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8521 assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
8522 assert!(
8523 n_colors <= 3,
8524 "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
8525 );
8526 assert!(var_color.iter().all(|&c| c != u32::MAX));
8527 }
8528
8529 /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
8530 /// scattered through an otherwise diagonal `n`-variable problem.
8531 /// A plain coloring needs exactly `size` colors no matter how many
8532 /// blocks there are, because the blocks share no rows.
8533 fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
8534 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8535 let stride = n / blocks;
8536 for b in 0..blocks {
8537 let base = b * stride;
8538 for i in 0..size {
8539 for j in 0..=i {
8540 if i != j {
8541 pairs.push((base + i, base + j));
8542 }
8543 }
8544 }
8545 }
8546 pairs
8547 }
8548
8549 /// Many medium-degree columns must not be peeled.
8550 ///
8551 /// Regression: selecting candidates by "degree > 16x average" and then
8552 /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
8553 /// The columns that miss the cut stay in the conflict structure, so the
8554 /// base color count is untouched and the singleton colors are pure
8555 /// addition — these two patterns colored to 306 and 290 against a plain
8556 /// walk's 50 and 34.
8557 #[test]
8558 fn thousands_of_medium_degree_cols_are_not_peeled() {
8559 for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
8560 let pairs = disjoint_blocks(n, blocks, size);
8561 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8562 let n_peeled = peeled.iter().filter(|&&p| p).count();
8563 assert_eq!(
8564 n_peeled, 0,
8565 "degree-{size} columns do not pay for a singleton color \
8566 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
8567 );
8568 assert!(
8569 n_colors <= size + 1,
8570 "disjoint {size}x{size} blocks color by block size regardless \
8571 of block count; got {n_colors} for n={n}, blocks={blocks}"
8572 );
8573 }
8574 }
8575
8576 /// The pay-for-itself rule must not throw away the case peeling exists
8577 /// for: a handful of genuinely dense rows still get peeled, and still
8578 /// collapse the coloring.
8579 #[test]
8580 fn a_few_truly_dense_rows_are_still_peeled() {
8581 let n = 5_000;
8582 let dense_rows = 4;
8583 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8584 for d in 0..dense_rows {
8585 for j in 0..n {
8586 if j != d {
8587 pairs.push((j.max(d), j.min(d)));
8588 }
8589 }
8590 }
8591 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8592 let n_peeled = peeled.iter().filter(|&&p| p).count();
8593 assert_eq!(n_peeled, dense_rows, "every full row should peel");
8594 assert!(
8595 n_colors <= dense_rows + 2,
8596 "peeling {dense_rows} full rows should leave a diagonal remainder, \
8597 got {n_colors} colors"
8598 );
8599 }
8600
8601 /// The cap still binds, and when it does the kept columns are the
8602 /// highest-degree ones.
8603 #[test]
8604 fn peeling_is_capped_and_keeps_the_worst_offenders() {
8605 let n = 20_000;
8606 let dense_rows = 400;
8607 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
8608 for d in 0..dense_rows {
8609 // Row d touches the first (n - d) columns, so degree strictly
8610 // decreases with d and the ordering is unambiguous.
8611 for j in 0..(n - d) {
8612 if j != d {
8613 pairs.push((j.max(d), j.min(d)));
8614 }
8615 }
8616 }
8617 let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
8618 let n_peeled = peeled.iter().filter(|&&p| p).count();
8619 assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
8620 assert!(
8621 (0..MAX_PEELED_COLS).all(|d| peeled[d]),
8622 "the {MAX_PEELED_COLS} densest rows are the ones kept"
8623 );
8624 }
8625
8626 /// Header line 0 is `g<count> <opt0> ...`; the option words are the
8627 /// model's own and a solver echoes them into the `.sol` `Options`
8628 /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
8629 #[test]
8630 fn header_option_words_are_kept_verbatim() {
8631 let p = parse_nl_text(EQ_LIN).expect("parse");
8632 assert_eq!(p.ampl_options, vec![0, 1, 0]);
8633 }
8634
8635 /// A count that does not match the words present must not be
8636 /// guessed at — the writer falls back rather than emit a wrong block.
8637 #[test]
8638 fn a_truncated_option_list_is_dropped_not_padded() {
8639 let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
8640 let p = parse_nl_text(&text).expect("parse");
8641 assert!(
8642 p.ampl_options.is_empty(),
8643 "9 declared but 3 present: {:?}",
8644 p.ampl_options
8645 );
8646 }
8647
8648 #[test]
8649 fn constrained_tnlp_eval_g_jac_h() {
8650 let p = parse_nl_text(EQ_LIN).expect("parse");
8651 let mut t = NlTnlp::new(p);
8652 let info = t.get_nlp_info().unwrap();
8653 assert_eq!(info.m, 1);
8654 assert_eq!(info.nnz_jac_g, 2);
8655
8656 // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
8657 let mut g = [0.0_f64; 1];
8658 assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
8659 assert!((g[0] - 0.7).abs() < 1e-12);
8660
8661 // Jacobian structure: row 0, cols [0, 1].
8662 let mut irow = [0_i32; 2];
8663 let mut jcol = [0_i32; 2];
8664 assert!(t.eval_jac_g(
8665 None,
8666 true,
8667 SparsityRequest::Structure {
8668 irow: &mut irow,
8669 jcol: &mut jcol
8670 }
8671 ));
8672 assert_eq!(irow, [0, 0]);
8673 assert_eq!(jcol, [0, 1]);
8674
8675 // Jacobian values: both 1.0.
8676 let mut vals = [0.0_f64; 2];
8677 assert!(t.eval_jac_g(
8678 Some(&[0.3, 0.4]),
8679 true,
8680 SparsityRequest::Values { values: &mut vals }
8681 ));
8682 assert!((vals[0] - 1.0).abs() < 1e-12);
8683 assert!((vals[1] - 1.0).abs() < 1e-12);
8684
8685 // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
8686 // λ contributes nothing because the constraint is linear, and
8687 // x0^2 + x1^2 is separable so there's no (1,0) entry in the
8688 // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
8689 assert_eq!(info.nnz_h_lag, 2);
8690 let mut hirow = [0_i32; 2];
8691 let mut hjcol = [0_i32; 2];
8692 assert!(t.eval_h(
8693 None,
8694 true,
8695 1.0,
8696 None,
8697 true,
8698 SparsityRequest::Structure {
8699 irow: &mut hirow,
8700 jcol: &mut hjcol
8701 }
8702 ));
8703 assert_eq!(hirow, [0, 1]);
8704 assert_eq!(hjcol, [0, 1]);
8705 let mut hvals = [0.0_f64; 2];
8706 assert!(t.eval_h(
8707 Some(&[0.3, 0.4]),
8708 true,
8709 1.0,
8710 Some(&[0.5]),
8711 true,
8712 SparsityRequest::Values { values: &mut hvals }
8713 ));
8714 assert!((hvals[0] - 2.0).abs() < 1e-12);
8715 assert!((hvals[1] - 2.0).abs() < 1e-12);
8716 }
8717
8718 /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
8719 /// encoded as common-subexpression `V2`. Header line 10 declares
8720 /// one obj-only CSE; expression tree references `v2` twice.
8721 const CSE_OBJ: &str = "g3 0 1 0
87222 0 1 0 0
87230 1
87240 0
87250 2 0
87260 0 0 1
87270 0 0 0 0
87280 0
87290 0
87300 1 0 0 0
8731V2 0 0
8732o0
8733v0
8734v1
8735O0 0
8736o0
8737o5
8738v2
8739n2
8740v2
8741b
87423
87433
8744";
8745
8746 #[test]
8747 fn parses_v_segment_cse() {
8748 let p = parse_nl_text(CSE_OBJ).expect("parse");
8749 assert_eq!(p.n, 2);
8750 // f(1,2) = 9 + 3 = 12
8751 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
8752 assert!((f - 12.0).abs() < 1e-12, "got {f}");
8753 // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
8754 let mut g = [0.0_f64; 2];
8755 grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
8756 assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
8757 assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
8758 // collect_vars reaches into the CSE body and finds {0, 1}.
8759 let mut vs = BTreeSet::new();
8760 p.obj_nonlinear.collect_vars(&mut vs);
8761 assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
8762 }
8763
8764 /// `min (x0 - 1)^2` with three suffix segments attached: an
8765 /// integer constraint-suffix (target=1, kind=1), an integer var-
8766 /// suffix (target=0, kind=0), and a real var-suffix (target=0,
8767 /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
8768 /// `<idx> <value>` lines.
8769 const WITH_SUFFIXES: &str = "g3 0 1 0
87701 0 1 0 0
87710 1
87720 0
87730 1 0
87740 0 0 1
87750 0 0 0 0
87760 0
87770 0
87780 0 0 0 0
8779O0 0
8780o5
8781o1
8782v0
8783n1
8784n2
8785b
87863
8787S0 1 sens_state_1
87880 7
8789S4 1 sens_state_value_1
87900 4.5
8791";
8792
8793 #[test]
8794 fn parses_var_int_and_var_real_suffixes() {
8795 let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
8796 // Integer var-suffix: dense length 1, slot 0 = 7.
8797 let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
8798 assert_eq!(v.as_slice(), &[7]);
8799 // Real var-suffix: dense length 1, slot 0 = 4.5.
8800 let r = p
8801 .suffixes
8802 .var_real
8803 .get("sens_state_value_1")
8804 .expect("var_real");
8805 assert_eq!(r.len(), 1);
8806 assert!((r[0] - 4.5).abs() < 1e-12);
8807 // Other suffix slots stay empty.
8808 assert!(p.suffixes.con_int.is_empty());
8809 assert!(p.suffixes.con_real.is_empty());
8810 }
8811
8812 /// Two-variable + two-constraint problem with a constraint-level
8813 /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
8814 const WITH_CON_SUFFIX: &str = "g3 0 1 0
88152 2 1 0 0
88160 0
88170 0
88180 2 0
88190 0 0 1
88200 0 0 0 0
88214 0
88220 0
88230 0 0 0 0 0
8824C0
8825n0
8826C1
8827n0
8828O0 0
8829n0
8830r
88314 0.0
88324 0.0
8833b
88343
88353
8836k1
88370
8838J0 2
88390 1
88401 1
8841J1 2
88420 1
88431 -1
8844S1 2 sens_init_constr
88450 1
88461 2
8847";
8848
8849 #[test]
8850 fn parses_con_int_suffix() {
8851 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
8852 let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
8853 // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
8854 assert_eq!(s.as_slice(), &[1, 2]);
8855 }
8856
8857 // ---- gh#785: a truncated file is rejected, not silently defaulted ----
8858 //
8859 // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
8860 // segment boundary is exactly the failure the issue reports: an
8861 // interrupted write. Each cut loses a different first segment and each
8862 // is caught by a different check, so all three are asserted — and the
8863 // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
8864 // what keeps these from passing against a parser that rejects
8865 // everything.
8866
8867 /// Everything from `at` (a segment header line) onward is gone.
8868 fn truncate_before(txt: &str, at: &str) -> String {
8869 let cut = txt
8870 .find(at)
8871 .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
8872 txt[..cut].to_string()
8873 }
8874
8875 #[test]
8876 fn truncation_before_the_row_bounds_is_a_parse_error() {
8877 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
8878 .expect_err("truncated file must not parse");
8879 assert!(
8880 err.contains("`r` (constraint-bounds) segment"),
8881 "error should name the missing segment: {err}"
8882 );
8883 }
8884
8885 #[test]
8886 fn truncation_before_the_variable_bounds_is_a_parse_error() {
8887 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
8888 .expect_err("truncated file must not parse");
8889 assert!(
8890 err.contains("`b` (variable-bounds) segment"),
8891 "error should name the missing segment: {err}"
8892 );
8893 }
8894
8895 /// The cut that leaves every bound in place and takes only the
8896 /// coefficients. Neither presence check can see it; the declared-vs-
8897 /// parsed nonzero count is the whole of the evidence.
8898 #[test]
8899 fn truncation_before_the_jacobian_is_a_parse_error() {
8900 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
8901 .expect_err("truncated file must not parse");
8902 assert!(
8903 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
8904 "error should report the mismatch: {err}"
8905 );
8906 }
8907
8908 /// The mismatch is an equality, not a floor: a file supplying *more*
8909 /// entries than it declares is as corrupt as one supplying fewer, and a
8910 /// `>=` check would wave it through.
8911 #[test]
8912 fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
8913 let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
8914 let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
8915 assert!(
8916 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
8917 "error should report the mismatch: {err}"
8918 );
8919 }
8920
8921 /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
8922 /// (n = m = 2) and hand back everything the engine would see.
8923 fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
8924 let mut obj = 1.0;
8925 let mut use_x = false;
8926 let mut x = vec![0.0; 2];
8927 let mut use_g = false;
8928 let mut g = vec![0.0; 2];
8929 let ok = tnlp.get_scaling_parameters(ScalingRequest {
8930 obj_scaling: &mut obj,
8931 use_x_scaling: &mut use_x,
8932 x_scaling: &mut x,
8933 use_g_scaling: &mut use_g,
8934 g_scaling: &mut g,
8935 });
8936 (ok, obj, use_x, x, use_g, g)
8937 }
8938
8939 /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
8940 /// the objective (`S6`) and one constraint (`S5`) reaches the
8941 /// engine's `user-scaling` pathway. The untagged second row is
8942 /// unscaled — its AMPL suffix default is 0, which is not a usable
8943 /// scale factor and reads as "not tagged".
8944 #[test]
8945 fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
8946 let nl = WITH_CON_SUFFIX.to_string()
8947 + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
8948 let p = parse_nl_text(&nl).expect("parse");
8949 let mut tnlp = NlTnlp::new(p);
8950 let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
8951 assert!(ok, "a tagged model must supply scaling");
8952 assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
8953 assert!(use_g);
8954 assert_eq!(g, vec![10.0, 1.0]);
8955 assert!(!use_x, "no variable suffix was declared");
8956 }
8957
8958 /// Variable-level `scaling_factor` entries are passed through, not
8959 /// dropped on the floor: `OrigIpoptNlp` does not model them and
8960 /// refuses the solve, which is the whole point of gh#483.
8961 #[test]
8962 fn scaling_factor_suffix_forwards_variable_factors() {
8963 let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
8964 let p = parse_nl_text(&nl).expect("parse");
8965 let mut tnlp = NlTnlp::new(p);
8966 let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
8967 assert!(ok);
8968 assert!(use_x, "variable factors must reach the engine");
8969 assert_eq!(x, vec![1.0, 3.0]);
8970 }
8971
8972 /// gh #703: with curvature-based scaling switched on, the computed
8973 /// factors are the base and a `scaling_factor` suffix the model
8974 /// actually carries wins **component by component** — an explicit
8975 /// factor from the modeller beats one inferred from the coefficients,
8976 /// and the components they did not tag keep the inferred one rather
8977 /// than snapping back to 1.
8978 #[test]
8979 fn a_user_suffix_overrides_the_computed_factor_component_wise() {
8980 let baseline = {
8981 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
8982 let mut t = NlTnlp::new(p);
8983 assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
8984 scaling_of(&mut t).5
8985 };
8986 assert!(
8987 baseline.iter().all(|v| *v > 0.0),
8988 "curvature scaling should produce usable row factors, got {baseline:?}"
8989 );
8990
8991 // Tag row 0 only. AMPL's untagged default is 0, which must read as
8992 // "not tagged" and leave row 1 on the computed factor.
8993 let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
8994 let p = parse_nl_text(&nl).expect("parse");
8995 let mut t = NlTnlp::new(p);
8996 assert!(t.enable_curvature_scaling());
8997 let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
8998 assert!(ok);
8999 assert!(use_g && use_x);
9000 assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
9001 assert_eq!(
9002 g[1], baseline[1],
9003 "the untagged row keeps the computed one, not 1.0"
9004 );
9005 }
9006
9007 /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
9008 /// same answer the default `TNLP` impl gives, so `user-scaling`
9009 /// falls back to no scaling instead of a bogus all-zero vector.
9010 #[test]
9011 fn no_scaling_factor_suffix_declines() {
9012 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
9013 let mut tnlp = NlTnlp::new(p);
9014 let (ok, ..) = scaling_of(&mut tnlp);
9015 assert!(!ok);
9016 }
9017
9018 #[test]
9019 fn rejects_suffix_with_out_of_range_index() {
9020 let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
9021 let err = parse_nl_text(&bad).expect_err("must reject");
9022 assert!(
9023 err.contains("out of range"),
9024 "expected out-of-range error, got: {err}"
9025 );
9026 }
9027
9028 #[test]
9029 fn tnlp_round_trip_solves() {
9030 let p = parse_nl_text(SIMPLE).expect("parse");
9031 let mut tnlp = NlTnlp::new(p);
9032 let info = tnlp.get_nlp_info().unwrap();
9033 assert_eq!(info.n, 2);
9034 assert_eq!(info.m, 0);
9035 let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
9036 assert!((f0 - 5.0).abs() < 1e-12);
9037 let mut g = [0.0_f64; 2];
9038 tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
9039 // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
9040 assert!((g[0] - (-2.0)).abs() < 1e-12);
9041 assert!((g[1] - (-4.0)).abs() < 1e-12);
9042 }
9043
9044 // ---- Sibling `.col` / `.row` name-file capture --------------------
9045 //
9046 // Names let diagnostics name the offending equation instead of "row 3"
9047 // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
9048 // the read path and the documented fallback-to-empty behavior.
9049
9050 use pounce_nlp::expression_provider::ExpressionProvider;
9051 use std::sync::atomic::{AtomicUsize, Ordering};
9052
9053 /// Unique scratch dir for one test (no `tempfile` dev-dep available).
9054 fn scratch_dir(tag: &str) -> std::path::PathBuf {
9055 static N: AtomicUsize = AtomicUsize::new(0);
9056 let seq = N.fetch_add(1, Ordering::Relaxed);
9057 let dir = std::env::temp_dir().join(format!(
9058 "pounce_nlnames_{}_{}_{}",
9059 std::process::id(),
9060 tag,
9061 seq
9062 ));
9063 std::fs::create_dir_all(&dir).expect("create scratch dir");
9064 dir
9065 }
9066
9067 #[test]
9068 fn read_name_file_reads_in_order() {
9069 let dir = scratch_dir("col_order");
9070 let p = dir.join("m.col");
9071 std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
9072 assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
9073 }
9074
9075 #[test]
9076 fn read_name_file_truncates_extra_lines() {
9077 // `.row` conventionally appends the objective name after the m
9078 // constraint names; `.take(expected)` must drop it so names stay
9079 // 1:1 with `g`.
9080 let dir = scratch_dir("row_obj");
9081 let p = dir.join("m.row");
9082 std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
9083 assert_eq!(
9084 read_name_file(&p, 2),
9085 vec!["mass_balance", "energy_balance"]
9086 );
9087 }
9088
9089 #[test]
9090 fn read_name_file_empty_on_short_or_missing() {
9091 let dir = scratch_dir("short");
9092 let short = dir.join("m.col");
9093 std::fs::write(&short, "only_one\n").unwrap();
9094 // Fewer lines than expected ⇒ empty (never a partial mapping).
9095 assert!(read_name_file(&short, 3).is_empty());
9096 // Missing file ⇒ empty, no error.
9097 assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
9098 }
9099
9100 #[test]
9101 fn read_nl_file_captures_sibling_names() {
9102 // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
9103 // names ride through onto the TNLP's ExpressionProvider.
9104 let dir = scratch_dir("sibling");
9105 let nl = dir.join("m.nl");
9106 std::fs::write(&nl, SIMPLE).unwrap();
9107 std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
9108
9109 let prob = read_nl_file(&nl).expect("parse + name capture");
9110 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9111 assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
9112
9113 let tnlp = NlTnlp::new(prob);
9114 assert_eq!(tnlp.variable_name(0), Some("alpha"));
9115 assert_eq!(tnlp.variable_name(1), Some("beta"));
9116 assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
9117 }
9118
9119 #[test]
9120 fn read_nl_file_without_names_yields_empty() {
9121 let dir = scratch_dir("noname");
9122 let nl = dir.join("m.nl");
9123 std::fs::write(&nl, SIMPLE).unwrap();
9124 let prob = read_nl_file(&nl).expect("parse");
9125 assert!(prob.var_names.is_empty());
9126 assert!(prob.con_names.is_empty());
9127 let tnlp = NlTnlp::new(prob);
9128 assert_eq!(tnlp.variable_name(0), None);
9129 }
9130
9131 #[test]
9132 fn read_nl_file_resolves_extensionless_ampl_stub() {
9133 // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
9134 // the `.nl` extension; the solver must read `mystub.nl`. Code
9135 // review 2026-06 item M15.
9136 let dir = scratch_dir("stub");
9137 std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
9138 // Pass the extensionless stub — the file `mystub` does not exist.
9139 let stub = dir.join("mystub");
9140 assert!(!stub.exists(), "stub must be extensionless / absent");
9141 let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
9142 assert_eq!(prob.n, 2);
9143 assert_eq!(prob.m, 0);
9144
9145 // Sibling name files are still found off the resolved stem.
9146 std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
9147 let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
9148 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
9149 }
9150
9151 #[test]
9152 fn read_nl_file_prefers_exact_path_over_nl_sibling() {
9153 // An existing path is read verbatim — the `.nl` fallback only
9154 // kicks in when the literal path is missing, so a caller passing a
9155 // real file is never silently redirected to a `<file>.nl` sibling.
9156 let dir = scratch_dir("exact");
9157 // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
9158 std::fs::write(dir.join("data"), SIMPLE).unwrap();
9159 std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
9160 let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
9161 assert_eq!(prob.n, 2);
9162 }
9163
9164 #[test]
9165 fn append_extension_appends_rather_than_replaces() {
9166 use std::path::Path;
9167 assert_eq!(
9168 append_extension(Path::new("mystub"), "nl"),
9169 Path::new("mystub.nl")
9170 );
9171 // A stub that itself contains a dot keeps its stem (AMPL names it
9172 // `my.model.nl`, not `my.nl`).
9173 assert_eq!(
9174 append_extension(Path::new("my.model"), "nl"),
9175 Path::new("my.model.nl")
9176 );
9177 }
9178
9179 // ---- equation rendering (`print equation`) ----
9180
9181 fn names(v: &[&str]) -> Vec<String> {
9182 v.iter().map(|s| s.to_string()).collect()
9183 }
9184
9185 #[test]
9186 fn render_uses_variable_names_when_present() {
9187 let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9188 assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
9189 // Falls back to x[i] when names are absent.
9190 assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
9191 }
9192
9193 #[test]
9194 fn render_parenthesizes_by_precedence() {
9195 // (x0 + x1) * x2 must keep the parens around the sum.
9196 let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
9197 let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
9198 assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
9199
9200 // x0 + x1 * x2 needs no parens (mul binds tighter).
9201 let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9202 let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
9203 assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
9204 }
9205
9206 #[test]
9207 fn render_subtraction_right_assoc_parens() {
9208 // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
9209 let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
9210 let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
9211 assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
9212 }
9213
9214 #[test]
9215 fn render_functions_and_pow() {
9216 let sq = Expr::Binary(
9217 BinOp::Pow,
9218 Box::new(Expr::Var(0)),
9219 Box::new(Expr::Const(2.0)),
9220 );
9221 let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
9222 assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
9223 }
9224
9225 #[test]
9226 fn render_linear_signs_are_tidy() {
9227 // 1*a - 2*b + c (coef +1 omits the multiplier).
9228 let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
9229 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
9230 }
9231
9232 #[test]
9233 fn render_linear_skips_zero_coefficients() {
9234 // A 0 coefficient (a variable present only in the nonlinear part)
9235 // is dropped, not rendered as `0*x`.
9236 let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
9237 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
9238 // Leading term zero ⇒ the first emitted term still has no ` + `.
9239 let lin = vec![(0usize, 0.0), (1, 2.0)];
9240 assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
9241 }
9242
9243 #[test]
9244 fn render_sum_folds_negative_terms() {
9245 // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
9246 let sq = |i| {
9247 Expr::Binary(
9248 BinOp::Pow,
9249 Box::new(Expr::Var(i)),
9250 Box::new(Expr::Const(2.0)),
9251 )
9252 };
9253 let neg = |i| {
9254 Expr::Binary(
9255 BinOp::Mul,
9256 Box::new(Expr::Const(-1.0)),
9257 Box::new(Expr::Var(i)),
9258 )
9259 };
9260 let e = Expr::Sum(vec![
9261 sq(0),
9262 neg(1),
9263 Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
9264 ]);
9265 assert_eq!(
9266 render_expr(&e, &names(&["a", "b", "c"]), &[]),
9267 "a^2 - 1*b - c"
9268 );
9269 }
9270
9271 #[test]
9272 fn render_constraint_equation_forms() {
9273 // Build a 2-constraint problem by hand: an equality and a range.
9274 let mut prob = parse_nl_text(SIMPLE).unwrap();
9275 // Overwrite to a known small shape: 1 var, 2 cons.
9276 prob.n = 2;
9277 prob.m = 2;
9278 prob.var_names = names(&["mass_in", "mass_out"]);
9279 prob.con_names = names(&["balance", "window"]);
9280 prob.con_linear = vec![
9281 vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
9282 vec![(0, 1.0)], // mass_in
9283 ];
9284 prob.con_nonlinear = vec![
9285 NlBody::Tree(Expr::Const(0.0)),
9286 NlBody::Tree(Expr::Const(0.0)),
9287 ];
9288 prob.g_l = vec![0.0, 0.0];
9289 prob.g_u = vec![0.0, 500.0];
9290
9291 assert_eq!(
9292 render_constraint_equation(&prob, 0),
9293 "mass_in - mass_out = 0"
9294 );
9295 assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
9296
9297 let all = render_all_constraint_equations(&prob);
9298 assert_eq!(all.len(), 2);
9299 assert_eq!(all[1], "0 <= mass_in <= 500");
9300 }
9301
9302 #[test]
9303 fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
9304 let mut prob = parse_nl_text(SIMPLE).unwrap();
9305 prob.n = 3;
9306 prob.m = 2;
9307 // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
9308 // Row 1: linear in x2 only → support {2}.
9309 prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
9310 prob.con_nonlinear = vec![
9311 NlBody::Tree(Expr::Binary(
9312 BinOp::Mul,
9313 Box::new(Expr::Var(0)),
9314 Box::new(Expr::Var(2)),
9315 )),
9316 NlBody::Tree(Expr::Const(0.0)),
9317 ];
9318 prob.g_l = vec![0.0, 0.0];
9319 prob.g_u = vec![0.0, 0.0];
9320
9321 let (irow, jcol) = constraint_jacobian_sparsity(&prob);
9322 // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
9323 assert_eq!(irow, vec![0, 0, 0, 1]);
9324 assert_eq!(jcol, vec![0, 1, 2, 2]);
9325 }
9326
9327 #[test]
9328 fn funcall_string_arg_with_hash_is_not_truncated() {
9329 // Code review L31: an AMPL string argument is a Hollerith literal
9330 // `h<len>:<chars>` whose content is exactly <len> bytes and may
9331 // legitimately contain '#' (e.g. a parameters-directory path). The
9332 // old parser ran strip_comment() over the line first, truncating
9333 // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
9334 let mut p = Parser::new("h3:a#b\n", false);
9335 match p.parse_funcall_arg().expect("parse hollerith arg") {
9336 FuncallArg::Str(s) => assert_eq!(s, "a#b"),
9337 other => panic!("expected Str, got {other:?}"),
9338 }
9339 }
9340
9341 #[test]
9342 fn funcall_string_arg_honors_declared_length() {
9343 // The declared `<len>` is authoritative: exactly that many bytes
9344 // after the ':' form the string; trailing content (here a real
9345 // ` # comment`) is not part of it.
9346 let mut p = Parser::new("h3:abc # trailing comment\n", false);
9347 match p.parse_funcall_arg().expect("parse hollerith arg") {
9348 FuncallArg::Str(s) => assert_eq!(s, "abc"),
9349 other => panic!("expected Str, got {other:?}"),
9350 }
9351 }
9352
9353 // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
9354 //
9355 // AMPL emits these in place of the general `o5` (OPPOW) when one operand
9356 // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
9357 // negative-base-safe constant-power lowering applies. The eval points below
9358 // are chosen to pin down BOTH the arity and the operand order: a swapped
9359 // `base`/`exp` (or treating `o82` as a different unary op) gives a
9360 // different number at these points, so each assertion is discriminating.
9361
9362 /// Parse a single expression `expr_src` with `n` variables in scope,
9363 /// driving the real `parse_opcode` path through `parse_expr`.
9364 fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
9365 let mut p = Parser::new(expr_src, false);
9366 p.n = n;
9367 p.parse_expr().expect("parse expression")
9368 }
9369
9370 #[test]
9371 fn opcode_o82_square_is_unary_pow_of_two() {
9372 // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
9373 let e = parse_one_expr(1, "o82\nv0\n");
9374 match &e {
9375 Expr::Binary(BinOp::Pow, base, exp) => {
9376 assert!(matches!(**base, Expr::Var(0)));
9377 match **exp {
9378 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
9379 ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
9380 }
9381 }
9382 other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
9383 }
9384 // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
9385 // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
9386 assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
9387 assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
9388 // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
9389 let mut g = [0.0_f64; 1];
9390 grad_expr(&e, &[3.0], 1.0, &mut g);
9391 assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
9392 g[0] = 0.0;
9393 grad_expr(&e, &[-3.0], 1.0, &mut g);
9394 assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
9395 }
9396
9397 #[test]
9398 fn opcode_o81_const_exponent_is_base_pow_const() {
9399 // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
9400 let e = parse_one_expr(1, "o81\nv0\nn3\n");
9401 match &e {
9402 Expr::Binary(BinOp::Pow, base, exp) => {
9403 assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
9404 match **exp {
9405 Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
9406 ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
9407 }
9408 }
9409 other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
9410 }
9411 // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
9412 assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
9413 // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
9414 // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
9415 assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
9416 // gradient d/dx x^3 = 3x^2 = 12 at x=2.
9417 let mut g = [0.0_f64; 1];
9418 grad_expr(&e, &[2.0], 1.0, &mut g);
9419 assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
9420 }
9421
9422 #[test]
9423 fn opcode_o83_const_base_is_const_pow_exp() {
9424 // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
9425 let e = parse_one_expr(1, "o83\nn2\nv0\n");
9426 match &e {
9427 Expr::Binary(BinOp::Pow, base, exp) => {
9428 match **base {
9429 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
9430 ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
9431 }
9432 assert!(
9433 matches!(**exp, Expr::Var(0)),
9434 "exponent must be the variable"
9435 );
9436 }
9437 other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
9438 }
9439 // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
9440 assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
9441 assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
9442 // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
9443 let mut g = [0.0_f64; 1];
9444 grad_expr(&e, &[3.0], 1.0, &mut g);
9445 assert!(
9446 (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
9447 "grad at 3 = {} (want {})",
9448 g[0],
9449 8.0 * 2.0_f64.ln()
9450 );
9451 }
9452
9453 #[test]
9454 fn power_specializations_agree_with_general_o5() {
9455 // Where both are defined, o81/o82/o83 must be numerically identical to
9456 // the general `o5` pow on the same operands — they are only routing
9457 // hints, not different math.
9458 let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
9459 let o82 = parse_one_expr(1, "o82\nv0\n");
9460 let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
9461 let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
9462 let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
9463 let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
9464 for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
9465 assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
9466 assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
9467 // 2^x is real for all x; compare across the same points.
9468 assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
9469 }
9470 }
9471
9472 #[test]
9473 fn power_opcodes_round_trip_through_parse_nl_text() {
9474 // End-to-end through the public entry point: `min x0^2 + x1^2` written
9475 // with o82 (square) parses and evaluates like its o5 twin. Reuses the
9476 // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
9477 let nl = SIMPLE.replace(
9478 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9479 "o0\no82\nv0\no82\nv1\n",
9480 );
9481 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9482 let p = parse_nl_text(&nl).expect("parse o82 objective");
9483 // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
9484 assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
9485 assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
9486 }
9487
9488 #[test]
9489 fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
9490 // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
9491 // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
9492 // (the negative-base-safe path) rather than a generic `powf`. The check
9493 // at a NEGATIVE base is the one that would break if o81 wrongly routed
9494 // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
9495 let nl = SIMPLE.replace(
9496 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
9497 "o0\no81\nv0\nn3\no81\nv1\nn3\n",
9498 );
9499 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
9500 let p = parse_nl_text(&nl).expect("parse o81 objective");
9501 let mut tnlp = NlTnlp::new(p);
9502 tnlp.get_nlp_info().unwrap();
9503 // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
9504 let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
9505 assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
9506 // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
9507 let mut g = [0.0_f64; 2];
9508 assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
9509 assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
9510 assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
9511 }
9512
9513 // ---- Shared-CSE constraint tape (issue #476) ----------------------
9514
9515 /// Three constraints over one `V` segment (a `.nl` *defined variable*),
9516 /// `V3 = 2*x0 + 3*x1`, referenced by all three:
9517 /// C0: V3^2 C1: V3^3 + x2 C2: V3*x2
9518 /// `{BODY2}` is a substitution point so a variant can drop an opcode the
9519 /// hybrid path rejects into C2.
9520 const SHARED_CSE: &str = "g3 1 1 0
9521 3 3 1 0 0
9522 3 0
9523 0 0
9524 3 0 0
9525 0 0 0 1
9526 0 0 0 0 0
9527 8 3
9528 0 0
9529 0 1 0 0 0
9530V3 2 0
95310 2.0
95321 3.0
9533n0
9534C0
9535o5
9536v3
9537n2
9538C1
9539o0
9540o5
9541v3
9542n3
9543v2
9544C2
9545{BODY2}
9546O0 0
9547n0
9548r
95492 0
95502 0
95512 0
9552b
95533
95543
95553
9556k2
95573
95586
9559J0 2
95600 0
95611 0
9562J1 3
95630 0
95641 0
95652 0
9566J2 3
95670 0
95681 0
95692 0
9570G0 3
95710 1.0
95721 1.0
95732 1.0
9574";
9575
9576 fn shared_cse_nl(body2: &str) -> String {
9577 SHARED_CSE.replace("{BODY2}", body2)
9578 }
9579
9580 /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
9581 /// per-summand tapes return — it is a different traversal of the same
9582 /// DAG, not a different derivative. Forced on here regardless of
9583 /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
9584 /// the size heuristic that decides when to use it.
9585 #[test]
9586 fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
9587 let nl = shared_cse_nl("o2\nv3\nv2");
9588 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9589
9590 let mut hybrid = NlTnlp::new(p.clone());
9591 let info = hybrid.get_nlp_info().unwrap();
9592 let nnz = info.nnz_jac_g as usize;
9593 hybrid
9594 .con_hybrid
9595 .as_mut()
9596 .expect("CSE shared by 3 constraints must build the hybrid tape")
9597 .use_for_jac = true;
9598
9599 let mut flat = NlTnlp::new(p);
9600 flat.get_nlp_info().unwrap();
9601 flat.con_hybrid = None;
9602
9603 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9604 let mut jh = vec![0.0_f64; nnz];
9605 let mut jf = vec![0.0_f64; nnz];
9606 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9607 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9608 assert_eq!(
9609 jh, jf,
9610 "hybrid Jacobian differs from the flat tape at {x:?}"
9611 );
9612
9613 // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
9614 let s = 2.0 * x[0] + 3.0 * x[1];
9615 let want = [
9616 4.0 * s,
9617 6.0 * s,
9618 6.0 * s * s,
9619 9.0 * s * s,
9620 1.0,
9621 2.0 * x[2],
9622 3.0 * x[2],
9623 s,
9624 ];
9625 assert_eq!(nnz, want.len());
9626 for k in 0..nnz {
9627 assert!(
9628 (jh[k] - want[k]).abs() < 1e-9,
9629 "entry {k} at {x:?}: got {}, want {}",
9630 jh[k],
9631 want[k]
9632 );
9633 }
9634 }
9635 }
9636
9637 /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
9638 /// CSE `S = body(x0, x1)` (the caller passes the body's expression
9639 /// text, which must reference exactly `v0` and `v1`). A deep shared
9640 /// body over few local ops per row is the regime the op-ratio gates
9641 /// are meant to catch: no repository fixture has a CSE shared across
9642 /// constraints at all, so without this the gate-on paths have no
9643 /// natural coverage.
9644 fn shared_body_chain_nl(m: usize, body: &str) -> String {
9645 let n = m + 2;
9646 let nzc = 3 * m;
9647 let mut s = String::new();
9648 s.push_str("g3 1 1 0\n");
9649 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9650 s.push_str(&format!(" {m} 0\n 0 0\n"));
9651 s.push_str(&format!(" {n} 0 0\n"));
9652 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9653 s.push_str(&format!(" {nzc} {n}\n"));
9654 s.push_str(" 0 0\n 0 1 0 0 0\n");
9655 // The shared body.
9656 s.push_str(&format!("V{n} 0 0\n"));
9657 s.push_str(body);
9658 // Rows: S * x_{i+2}.
9659 for i in 0..m {
9660 s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
9661 }
9662 s.push_str("O0 0\nn0\n");
9663 s.push_str(&format!("x{n}\n"));
9664 for j in 0..n {
9665 s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
9666 }
9667 s.push_str("r\n");
9668 for _ in 0..m {
9669 s.push_str("2 0\n");
9670 }
9671 s.push_str("b\n");
9672 for _ in 0..n {
9673 s.push_str("3\n");
9674 }
9675 // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
9676 s.push_str(&format!("k{}\n", n - 1));
9677 let mut acc = 0;
9678 for j in 0..n - 1 {
9679 acc += if j < 2 { m } else { 1 };
9680 s.push_str(&format!("{acc}\n"));
9681 }
9682 for i in 0..m {
9683 s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
9684 }
9685 s.push_str(&format!("G0 {n}\n"));
9686 for j in 0..n {
9687 s.push_str(&format!("{j} 0.0\n"));
9688 }
9689 s
9690 }
9691
9692 /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
9693 /// The forward value saturates to `inf` past depth ≈ 5, which the
9694 /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
9695 /// bounded variant below instead, whose second-order terms would
9696 /// otherwise mix `inf`s of both signs into `NaN`.
9697 fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
9698 let mut body = String::new();
9699 for _ in 0..depth {
9700 body.push_str("o44\n");
9701 }
9702 body.push_str("o2\nn0.01\no0\nv0\nv1\n");
9703 shared_body_chain_nl(m, &body)
9704 }
9705
9706 /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
9707 /// (x0 + x1))` — mathematically the identity chain, so the value
9708 /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
9709 /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
9710 /// through both second-order sweeps. `2 * pairs` body ops drive the
9711 /// flat/shared op ratio as high as the Hessian gate tests need.
9712 fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
9713 let mut body = String::new();
9714 for _ in 0..pairs {
9715 body.push_str("o43\no44\n");
9716 }
9717 body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
9718 shared_body_chain_nl(m, &body)
9719 }
9720
9721 /// With a deep shared body the gate turns itself on, and the path it
9722 /// turns on must still agree with the flat tapes exactly.
9723 #[test]
9724 fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
9725 let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
9726
9727 let mut hybrid = NlTnlp::new(p.clone());
9728 let info = hybrid.get_nlp_info().unwrap();
9729 let nnz = info.nnz_jac_g as usize;
9730 assert!(
9731 hybrid
9732 .con_hybrid
9733 .as_ref()
9734 .expect("shared CSE must build the hybrid tape")
9735 .use_for_jac,
9736 "a 40-deep body shared by 16 rows is well past the op-ratio gate"
9737 );
9738
9739 let mut flat = NlTnlp::new(p);
9740 flat.get_nlp_info().unwrap();
9741 flat.con_hybrid = None;
9742
9743 for scale in [1.0_f64, -0.7, 2.5] {
9744 let x: Vec<f64> = (0..info.n as usize)
9745 .map(|j| scale * (0.2 + 0.03 * j as f64))
9746 .collect();
9747 let mut jh = vec![0.0_f64; nnz];
9748 let mut jf = vec![0.0_f64; nnz];
9749 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
9750 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
9751 assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
9752 assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
9753 }
9754 }
9755
9756 /// The Jacobian gate is off for a model whose shared bodies are small,
9757 /// because there the hybrid traversal's per-op overhead outweighs the
9758 /// halved forward sweep. `eval_g` still takes the hybrid path — that
9759 /// one is a win at any ratio.
9760 #[test]
9761 fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
9762 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
9763 let mut t = NlTnlp::new(p);
9764 t.get_nlp_info().unwrap();
9765 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
9766 assert!(
9767 !h.use_for_jac,
9768 "a 3-row model with a 2-term CSE is far below the op-ratio gate"
9769 );
9770 }
9771
9772 /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
9773 /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
9774 /// adjoints and running one prelude sweep reassociates floating-point
9775 /// products, so the two paths agree only to rounding on general inputs —
9776 /// but here every operation in both traversals is exact (dyadic values,
9777 /// small-integer coefficients, polynomial ops), so the results must be
9778 /// bit-identical, pinning the arithmetic itself and not just its
9779 /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
9780 /// path is covered independently of the size heuristic.
9781 #[test]
9782 fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
9783 let nl = shared_cse_nl("o2\nv3\nv2");
9784 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9785
9786 let mut hybrid = NlTnlp::new(p.clone());
9787 let info = hybrid.get_nlp_info().unwrap();
9788 let nnz = info.nnz_h_lag as usize;
9789 hybrid
9790 .con_hybrid
9791 .as_mut()
9792 .expect("CSE shared by 3 constraints must build the hybrid tape")
9793 .use_for_hess = true;
9794
9795 let mut flat = NlTnlp::new(p);
9796 flat.get_nlp_info().unwrap();
9797 flat.con_hybrid = None;
9798
9799 let pairs: Vec<(usize, usize)> = hybrid
9800 .h_irow
9801 .iter()
9802 .zip(&hybrid.h_jcol)
9803 .map(|(&i, &j)| (i as usize, j as usize))
9804 .collect();
9805
9806 // Two multiplier sets: one all-live, one with a dead row so the
9807 // λ == 0 skip is exercised on the hybrid path too.
9808 for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
9809 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9810 let mut hh = vec![0.0_f64; nnz];
9811 let mut hf = vec![0.0_f64; nnz];
9812 assert!(hybrid.eval_h(
9813 Some(&x),
9814 true,
9815 1.0,
9816 Some(&lam),
9817 true,
9818 SparsityRequest::Values { values: &mut hh }
9819 ));
9820 assert!(flat.eval_h(
9821 Some(&x),
9822 true,
9823 1.0,
9824 Some(&lam),
9825 true,
9826 SparsityRequest::Values { values: &mut hf }
9827 ));
9828 assert_eq!(
9829 hh, hf,
9830 "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
9831 );
9832
9833 // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
9834 // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
9835 // objective is constant, so the Lagrangian Hessian is
9836 // (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
9837 let s = 2.0 * x[0] + 3.0 * x[1];
9838 let q = 2.0 * lam[0] + 6.0 * s * lam[1];
9839 let dv = [2.0, 3.0, 0.0];
9840 for (k, &(i, j)) in pairs.iter().enumerate() {
9841 let mut want = q * dv[i] * dv[j];
9842 if i == 2 {
9843 want += lam[2] * dv[j];
9844 }
9845 if j == 2 {
9846 want += lam[2] * dv[i];
9847 }
9848 assert!(
9849 (hh[k] - want).abs() < 1e-9,
9850 "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
9851 hh[k]
9852 );
9853 }
9854 }
9855 }
9856 }
9857
9858 /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
9859 /// turns the Hessian gate on by itself, and the path it turns on must
9860 /// agree with the flat tapes. Not bitwise here: the shared prelude
9861 /// reverse sweep runs once over the `λ_k`-folded adjoints of all
9862 /// summands where the flat path runs per summand, and that
9863 /// reassociation moves transcendental results by rounding — so the bar
9864 /// is a relative few-ULP band, with the exact-arithmetic case pinned
9865 /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
9866 #[test]
9867 fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
9868 let m = 16;
9869 let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
9870
9871 let mut hybrid = NlTnlp::new(p.clone());
9872 let info = hybrid.get_nlp_info().unwrap();
9873 let nnz = info.nnz_h_lag as usize;
9874 assert!(
9875 hybrid
9876 .con_hybrid
9877 .as_ref()
9878 .expect("shared CSE must build the hybrid tape")
9879 .use_for_hess,
9880 "a 40-op body shared by 16 rows is well past the op-ratio gate"
9881 );
9882
9883 let mut flat = NlTnlp::new(p);
9884 flat.get_nlp_info().unwrap();
9885 flat.con_hybrid = None;
9886
9887 let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
9888 for scale in [1.0_f64, -0.7, 2.5] {
9889 let x: Vec<f64> = (0..info.n as usize)
9890 .map(|j| scale * (0.2 + 0.03 * j as f64))
9891 .collect();
9892 // Run the hybrid Jacobian first, the order a real solve
9893 // iteration uses. Its `gradient_summand` sweeps leave the
9894 // *Jacobian's* prelude adjoint arena dirty; the Hessian's
9895 // accumulators must be its own buffers with the all-zero-
9896 // between-colors invariant, or this seeds `eval_h` with a
9897 // stale row gradient (caught here).
9898 let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
9899 assert!(hybrid.eval_jac_g(
9900 Some(&x),
9901 true,
9902 SparsityRequest::Values { values: &mut jac }
9903 ));
9904 let mut hh = vec![0.0_f64; nnz];
9905 let mut hf = vec![0.0_f64; nnz];
9906 assert!(hybrid.eval_h(
9907 Some(&x),
9908 true,
9909 1.0,
9910 Some(&lam),
9911 true,
9912 SparsityRequest::Values { values: &mut hh }
9913 ));
9914 assert!(flat.eval_h(
9915 Some(&x),
9916 true,
9917 1.0,
9918 Some(&lam),
9919 true,
9920 SparsityRequest::Values { values: &mut hf }
9921 ));
9922 for k in 0..nnz {
9923 assert!(
9924 hh[k].is_finite() && hf[k].is_finite(),
9925 "non-finite Hessian entry {k} defeats the comparison"
9926 );
9927 let tol = 1e-12 * hf[k].abs().max(1.0);
9928 assert!(
9929 (hh[k] - hf[k]).abs() <= tol,
9930 "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
9931 hh[k],
9932 hf[k]
9933 );
9934 }
9935 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
9936 }
9937 }
9938
9939 /// `.nl` text for two independent shared-CSE blocks of different widths:
9940 /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
9941 /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
9942 /// of the form `body * x_r`.
9943 ///
9944 /// The width difference is the point. A body summing `w` variables gives
9945 /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
9946 /// conflict and the coloring must spend `w` colors on them; the narrower
9947 /// block reuses the low colors. The surplus colors therefore belong to
9948 /// block A alone, and a per-color prelude walk over *both* bodies would
9949 /// be doing work for a body that color cannot reach.
9950 fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
9951 let nvars = wide + narrow;
9952 let m = 2 * rows;
9953 let n = nvars + m;
9954 // Block A rows touch `wide` body vars + 1 row var; block B rows
9955 // touch `narrow` + 1.
9956 let nzc = rows * (wide + 1) + rows * (narrow + 1);
9957 let mut s = String::new();
9958 s.push_str("g3 1 1 0\n");
9959 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
9960 s.push_str(&format!(" {m} 0\n 0 0\n"));
9961 s.push_str(&format!(" {n} 0 0\n"));
9962 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
9963 s.push_str(&format!(" {nzc} {n}\n"));
9964 s.push_str(" 0 0\n 0 2 0 0 0\n");
9965 // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
9966 // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
9967 for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
9968 s.push_str(&format!("V{} 0 0\n", n + b));
9969 s.push_str("o41\n");
9970 for _ in 0..count - 1 {
9971 s.push_str("o0\n");
9972 }
9973 for j in 0..*count {
9974 s.push_str(&format!("v{}\n", base + j));
9975 }
9976 }
9977 // Rows: body_b * x_{rowvar}.
9978 for i in 0..m {
9979 let b = i / rows;
9980 s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
9981 }
9982 s.push_str("O0 0\nn0\n");
9983 s.push_str(&format!("x{n}\n"));
9984 for j in 0..n {
9985 s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
9986 }
9987 s.push_str("r\n");
9988 for _ in 0..m {
9989 s.push_str("2 0\n");
9990 }
9991 s.push_str("b\n");
9992 for _ in 0..n {
9993 s.push_str("3\n");
9994 }
9995 // Cumulative Jacobian column counts for the first n-1 columns.
9996 s.push_str(&format!("k{}\n", n - 1));
9997 let mut acc = 0;
9998 for j in 0..n - 1 {
9999 acc += if j < nvars { rows } else { 1 };
10000 s.push_str(&format!("{acc}\n"));
10001 }
10002 for i in 0..m {
10003 let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
10004 s.push_str(&format!("J{i} {}\n", count + 1));
10005 let mut cols: Vec<usize> = (base..base + count).collect();
10006 cols.push(nvars + i);
10007 cols.sort_unstable();
10008 for c in cols {
10009 s.push_str(&format!("{c} 0.0\n"));
10010 }
10011 }
10012 s.push_str(&format!("G0 {n}\n"));
10013 for j in 0..n {
10014 s.push_str(&format!("{j} 0.0\n"));
10015 }
10016 s
10017 }
10018
10019 /// Both prelude sweeps run once per color, so iterating the whole prelude
10020 /// each time would cost `n_colors × |prelude|` where the op-ratio gate
10021 /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
10022 /// `eval_h` instead walks the union of that color's summands'
10023 /// `prelude_reach`.
10024 ///
10025 /// What this can and cannot pin is worth being exact about, because the
10026 /// change is pure performance: walking the whole prelude per color
10027 /// computes the *same* Hessian, so no assertion on output values can
10028 /// detect it, and a timing assertion would be flaky. So this asserts the
10029 /// two things that are checkable — that the table the sweeps iterate is
10030 /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
10031 /// where colors genuinely reach different bodies, and that each reach list
10032 /// is ascending and operand-closed, the invariants that make the narrowed
10033 /// walk safe — plus agreement with the flat tapes under narrowing.
10034 #[test]
10035 fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
10036 let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
10037 let mut hybrid = NlTnlp::new(p.clone());
10038 let info = hybrid.get_nlp_info().unwrap();
10039 let nnz = info.nnz_h_lag as usize;
10040 let m = info.m as usize;
10041
10042 {
10043 let h = hybrid
10044 .con_hybrid
10045 .as_mut()
10046 .expect("two shared CSE bodies must build the hybrid tape");
10047 h.use_for_hess = true;
10048
10049 let np = h.tape.n_prelude_ops();
10050 let n_colors = h.hess_color_reach_off.len() - 1;
10051 let total: usize = h.hess_color_reach.len();
10052 assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
10053 assert!(
10054 total < n_colors * np,
10055 "per-color reach must be strictly smaller than walking the whole \
10056 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
10057 n_colors * np
10058 );
10059 // Every reach list must be ascending and operand-closed, which is
10060 // what makes the narrowed walk safe.
10061 for c in 0..n_colors {
10062 let r =
10063 &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
10064 assert!(
10065 r.windows(2).all(|w| w[0] < w[1]),
10066 "color {c} reach is not strictly ascending"
10067 );
10068 let member: std::collections::HashSet<u32> = r.iter().copied().collect();
10069 for &i in r {
10070 let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
10071 for opnd in [a, b].into_iter().flatten() {
10072 assert!(
10073 member.contains(&(opnd as u32)),
10074 "color {c}: slot {i}'s operand {opnd} is missing from its reach"
10075 );
10076 }
10077 }
10078 }
10079 }
10080
10081 let mut flat = NlTnlp::new(p);
10082 flat.get_nlp_info().unwrap();
10083 flat.con_hybrid = None;
10084
10085 let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
10086 for scale in [1.0_f64, -0.6] {
10087 let x: Vec<f64> = (0..info.n as usize)
10088 .map(|j| scale * (0.15 + 0.02 * j as f64))
10089 .collect();
10090 let mut hh = vec![0.0_f64; nnz];
10091 let mut hf = vec![0.0_f64; nnz];
10092 assert!(hybrid.eval_h(
10093 Some(&x),
10094 true,
10095 1.0,
10096 Some(&lam),
10097 true,
10098 SparsityRequest::Values { values: &mut hh }
10099 ));
10100 assert!(flat.eval_h(
10101 Some(&x),
10102 true,
10103 1.0,
10104 Some(&lam),
10105 true,
10106 SparsityRequest::Values { values: &mut hf }
10107 ));
10108 for k in 0..nnz {
10109 let tol = 1e-12 * hf[k].abs().max(1.0);
10110 assert!(
10111 (hh[k] - hf[k]).abs() <= tol,
10112 "narrowed-reach Hessian entry {k} at scale {scale}: \
10113 hybrid {} vs flat {}",
10114 hh[k],
10115 hf[k]
10116 );
10117 }
10118 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
10119 }
10120 }
10121
10122 /// The Hessian gate is off for a model whose shared bodies are small —
10123 /// below the ratio where the shared prelude sweeps pay for the hybrid
10124 /// traversal's per-op overhead — leaving `eval_h` on the flat path
10125 /// (which the gate keeps bit-identical for such models by definition).
10126 #[test]
10127 fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
10128 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
10129 let mut t = NlTnlp::new(p);
10130 t.get_nlp_info().unwrap();
10131 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
10132 assert!(
10133 !h.use_for_hess,
10134 "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
10135 );
10136 }
10137
10138 /// A CSE referenced from several constraints is evaluated once per
10139 /// `eval_g` via the shared prelude instead of once per reference. The
10140 /// values must be bit-identical to the flat per-summand `Tape` path,
10141 /// which is what makes the optimization safe to apply unconditionally.
10142 #[test]
10143 fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
10144 let nl = shared_cse_nl("o2\nv3\nv2");
10145 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
10146 let mut hybrid = NlTnlp::new(p.clone());
10147 hybrid.get_nlp_info().unwrap();
10148 let h = hybrid
10149 .con_hybrid
10150 .as_ref()
10151 .expect("CSE shared by 3 constraints must take the hybrid path");
10152 assert!(
10153 h.tape.n_prelude_ops() > 0,
10154 "shared CSE body must land in the prelude"
10155 );
10156
10157 // Same model with the hybrid path switched off: the reference.
10158 let mut flat = NlTnlp::new(p);
10159 flat.get_nlp_info().unwrap();
10160 flat.con_hybrid = None;
10161
10162 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
10163 let mut gh = [0.0_f64; 3];
10164 let mut gf = [0.0_f64; 3];
10165 assert!(hybrid.eval_g(&x, true, &mut gh));
10166 assert!(flat.eval_g(&x, true, &mut gf));
10167 // V3 = 2 x0 + 3 x1.
10168 let s = 2.0 * x[0] + 3.0 * x[1];
10169 let want = [s * s, s * s * s + x[2], s * x[2]];
10170 for i in 0..3 {
10171 assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
10172 assert!(
10173 (gh[i] - want[i]).abs() < 1e-9,
10174 "row {i}: got {}, want {}",
10175 gh[i],
10176 want[i]
10177 );
10178 }
10179 }
10180 }
10181
10182 /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
10183 /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
10184 /// take that path after `hybrid_supported` clears the model. Here a
10185 /// min-list in one constraint has to disable it for the whole block —
10186 /// falling back, not panicking.
10187 #[test]
10188 fn unsupported_opcode_falls_back_to_the_flat_tape() {
10189 let nl = shared_cse_nl("o11\n2\nv3\nv2");
10190 let p = parse_nl_text(&nl).expect("parse min-list model");
10191 let mut tnlp = NlTnlp::new(p);
10192 tnlp.get_nlp_info().unwrap();
10193 assert!(
10194 tnlp.con_hybrid.is_none(),
10195 "a min-list anywhere in the constraint block must disable the hybrid path"
10196 );
10197 let mut g = [0.0_f64; 3];
10198 assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
10199 let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
10200 assert!((g[0] - s * s).abs() < 1e-9);
10201 assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
10202 }
10203
10204 // ---- In-memory construction + HVP (issue #469) --------------------
10205
10206 fn v(i: usize) -> Expr {
10207 Expr::Var(i)
10208 }
10209
10210 fn c(x: Number) -> Expr {
10211 Expr::Const(x)
10212 }
10213
10214 fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
10215 Expr::Binary(op, Box::new(a), Box::new(b))
10216 }
10217
10218 fn un(op: UnaryOp, a: Expr) -> Expr {
10219 Expr::Unary(op, Box::new(a))
10220 }
10221
10222 /// `NlProblemParts` for an `n`-variable, unbounded model.
10223 fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
10224 let m = constraints.len();
10225 NlProblemParts {
10226 minimize: true,
10227 objective,
10228 obj_constant: 0.0,
10229 constraints,
10230 x_l: vec![-1e19; n],
10231 x_u: vec![1e19; n],
10232 x0: vec![0.0; n],
10233 g_l: vec![-1e19; m],
10234 g_u: vec![1e19; m],
10235 var_names: Vec::new(),
10236 con_names: Vec::new(),
10237 }
10238 }
10239
10240 /// A model built from expressions evaluates exactly like a parsed one:
10241 /// objective, gradient, constraints, and Jacobian all come from the
10242 /// same tape, with no `.nl` text in the loop.
10243 #[test]
10244 fn from_expressions_builds_evaluable_problem() {
10245 // min (1-x0)^2 + 100*(x1 - x0^2)^2 s.t. x0^2 + x1^2 <= 2
10246 let rosen = bin(
10247 BinOp::Add,
10248 bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
10249 bin(
10250 BinOp::Mul,
10251 c(100.0),
10252 bin(
10253 BinOp::Pow,
10254 bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
10255 c(2.0),
10256 ),
10257 ),
10258 );
10259 let circle = bin(
10260 BinOp::Add,
10261 bin(BinOp::Pow, v(0), c(2.0)),
10262 bin(BinOp::Pow, v(1), c(2.0)),
10263 );
10264
10265 let mut p = parts(2, rosen, vec![circle]);
10266 p.g_l = vec![0.0];
10267 p.g_u = vec![2.0];
10268 p.x0 = vec![-1.2, 1.0];
10269 p.var_names = names(&["x", "y"]);
10270 p.con_names = names(&["circle"]);
10271
10272 let prob = NlProblem::from_expressions(p).expect("build");
10273 assert_eq!((prob.n, prob.m), (2, 1));
10274 assert_eq!(prob.var_names, names(&["x", "y"]));
10275
10276 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10277 t.get_nlp_info().unwrap();
10278
10279 // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
10280 let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
10281 assert!((f - 24.2).abs() < 1e-10, "f = {f}");
10282
10283 // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
10284 // = (4.4 + 480*(-0.44)... ) — computed below rather than
10285 // transcribed, so the check is the formula, not an editor.
10286 let (x0, x1) = (-1.2, 1.0);
10287 let want = [
10288 -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
10289 200.0 * (x1 - x0 * x0),
10290 ];
10291 let mut g = [0.0_f64; 2];
10292 assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
10293 for j in 0..2 {
10294 assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
10295 }
10296
10297 // g(x) = x0^2 + x1^2 = 2.44
10298 let mut gv = [0.0_f64; 1];
10299 assert!(t.eval_g(&[x0, x1], true, &mut gv));
10300 assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
10301 }
10302
10303 /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
10304 /// this path. None of the three survives a `.nl` round trip in a
10305 /// typical frontend — `atan2` has no two-argument funcall path,
10306 /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
10307 /// at all — which is the reason the in-memory door exists.
10308 #[test]
10309 fn from_expressions_carries_ops_nl_cannot_express() {
10310 let obj = Expr::Sum(vec![
10311 bin(BinOp::Atan2, v(0), v(1)),
10312 Expr::MinList(vec![v(0), v(1)]),
10313 Expr::MaxList(vec![v(0), v(1)]),
10314 un(UnaryOp::Erf, v(0)),
10315 ]);
10316 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10317 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10318 t.get_nlp_info().unwrap();
10319
10320 let x: [Number; 2] = [0.8, 1.5];
10321 // atan2 + min + max + erf; min+max == x0+x1 for any pair.
10322 let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
10323 let f = t.eval_f(&x, true).unwrap();
10324 assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
10325 }
10326
10327 /// A `Var` index past `n` would be an out-of-bounds read in the
10328 /// tape's forward sweep. It has to be caught at construction, while
10329 /// it is still a diagnosable user error.
10330 #[test]
10331 fn from_expressions_rejects_out_of_range_var() {
10332 let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
10333 .expect_err("Var(5) with n = 2 must be rejected");
10334 assert!(err.contains("Var(5)"), "{err}");
10335
10336 let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
10337 .expect_err("constraint Var(2) with n = 2 must be rejected");
10338 assert!(err.contains("constraint 0"), "{err}");
10339
10340 // Length mismatches are errors too, not panics.
10341 let mut p = parts(2, c(0.0), Vec::new());
10342 p.x0 = vec![0.0; 3];
10343 let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
10344 assert!(err.contains("x0"), "{err}");
10345 }
10346
10347 /// An out-of-range `Var` must be caught wherever it hides, not just at
10348 /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
10349 /// branch, and a funcall argument all reach the same forward sweep.
10350 #[test]
10351 fn from_expressions_finds_out_of_range_vars_in_every_position() {
10352 let inner_cse = Arc::new(v(7));
10353 let cases: Vec<(&str, Expr)> = vec![
10354 ("bare", v(7)),
10355 ("cse", Expr::Cse(Arc::new(v(7)))),
10356 ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
10357 (
10358 "cond branch",
10359 Expr::Cond {
10360 cond: Box::new(c(1.0)),
10361 then_: Box::new(v(7)),
10362 else_: Box::new(c(0.0)),
10363 },
10364 ),
10365 ("min list", Expr::MinList(vec![c(0.0), v(7)])),
10366 (
10367 "sum",
10368 Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
10369 ),
10370 ];
10371 for (label, e) in cases {
10372 let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
10373 .err()
10374 .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
10375 assert!(err.contains("Var(7)"), "{label}: {err}");
10376 }
10377 }
10378
10379 /// A balanced share-DAG: each level is one `Cse` whose body
10380 /// references the level below twice. Depth `d` is `d` distinct nodes
10381 /// but `2^d` paths, so any walk that re-enters a shared body per
10382 /// occurrence is Θ(2^d).
10383 fn share_dag(depth: usize) -> Expr {
10384 let mut e = v(0);
10385 for _ in 0..depth {
10386 let shared = Arc::new(e);
10387 e = bin(
10388 BinOp::Add,
10389 Expr::Cse(Arc::clone(&shared)),
10390 Expr::Cse(shared),
10391 );
10392 }
10393 e
10394 }
10395
10396 /// The walks over a shared DAG must be memoized, not exponential.
10397 ///
10398 /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
10399 /// does not "fail" so much as never finish, which is exactly the
10400 /// signal. `from_expressions` is the door that makes such a DAG
10401 /// trivially constructible, but the blowup was reachable through
10402 /// `collect_vars` (which presolve calls on every solve, via
10403 /// `get_variables_linearity`) and `collect_funcall_ids` (which
10404 /// `NlTnlp::try_new` runs over every row).
10405 #[test]
10406 fn shared_dag_walks_are_memoized_not_exponential() {
10407 const DEPTH: usize = 30;
10408 let e = share_dag(DEPTH);
10409
10410 let mut vars = BTreeSet::new();
10411 collect_vars(&e, &mut vars);
10412 assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
10413
10414 let mut ids = BTreeSet::new();
10415 super::super::nl_external::collect_funcall_ids(&e, &mut ids);
10416 assert!(ids.is_empty());
10417
10418 // The whole build path, end to end: validation, tape construction,
10419 // and the linearity metadata presolve consumes.
10420 let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
10421 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10422 t.get_nlp_info().unwrap();
10423 let mut lin = vec![Linearity::Linear; 1];
10424 assert!(t.get_variables_linearity(&mut lin));
10425 }
10426
10427 /// `from_expressions` cannot carry AMPL imported functions — there is
10428 /// nowhere to put the `F`-segment declarations that bind a funcall id
10429 /// to a library — so a `Funcall` must be refused up front. Accepting it
10430 /// produces "AMPLFUNC is not set", which the user cannot act on:
10431 /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
10432 #[test]
10433 fn from_expressions_rejects_imported_function_calls() {
10434 let call = Expr::Funcall {
10435 id: 0,
10436 args: vec![FuncallArg::Real(v(0))],
10437 };
10438 let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
10439 .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
10440 assert!(err.contains("imported function"), "{err}");
10441 assert!(
10442 err.contains("read_nl") || err.contains("parse_nl_text"),
10443 "the error must point at the paths that do support externals: {err}"
10444 );
10445
10446 // Also when buried in a constraint, behind a Cse.
10447 let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
10448 let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
10449 .expect_err("a buried Funcall must be rejected too");
10450 assert!(err.contains("constraint 0"), "{err}");
10451 }
10452
10453 /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
10454 /// same tapes, same weights, one seed instead of a color sweep. The
10455 /// objective and both constraints are chosen with cross terms so the
10456 /// off-diagonal blocks actually carry signal.
10457 #[test]
10458 fn hessian_vector_product_matches_dense_hessian() {
10459 let obj = Expr::Sum(vec![
10460 bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
10461 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10462 un(UnaryOp::Erf, v(2)),
10463 ]);
10464 let cons = vec![
10465 bin(
10466 BinOp::Add,
10467 bin(BinOp::Pow, v(0), c(2.0)),
10468 un(UnaryOp::Sin, v(2)),
10469 ),
10470 bin(BinOp::Mul, v(1), v(2)),
10471 ];
10472 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10473 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10474 let info = t.get_nlp_info().unwrap();
10475
10476 let x = [0.3, -0.7, 1.1];
10477 let lam = [0.5, -1.25];
10478 let obj_factor = 2.0;
10479
10480 // Dense Hessian from the sparse lower triangle.
10481 let nnz = info.nnz_h_lag as usize;
10482 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10483 assert!(t.eval_h(
10484 None,
10485 false,
10486 1.0,
10487 None,
10488 false,
10489 SparsityRequest::Structure {
10490 irow: &mut irow,
10491 jcol: &mut jcol
10492 }
10493 ));
10494 let mut hvals = vec![0.0_f64; nnz];
10495 assert!(t.eval_h(
10496 Some(&x),
10497 true,
10498 obj_factor,
10499 Some(&lam),
10500 true,
10501 SparsityRequest::Values { values: &mut hvals }
10502 ));
10503 let mut dense = [[0.0_f64; 3]; 3];
10504 for k in 0..nnz {
10505 let (i, j) = (irow[k] as usize, jcol[k] as usize);
10506 dense[i][j] += hvals[k];
10507 if i != j {
10508 dense[j][i] += hvals[k];
10509 }
10510 }
10511
10512 // Each unit seed recovers a column; a mixed seed catches an HVP
10513 // that only happens to be right on the basis vectors.
10514 let seeds: [[Number; 3]; 4] = [
10515 [1.0, 0.0, 0.0],
10516 [0.0, 1.0, 0.0],
10517 [0.0, 0.0, 1.0],
10518 [0.4, -1.3, 2.0],
10519 ];
10520 let mut out = vec![0.0; 3];
10521 for s in &seeds {
10522 t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
10523 .expect("hvp");
10524 for i in 0..3 {
10525 let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
10526 assert!(
10527 (out[i] - want).abs() < 1e-9,
10528 "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
10529 out[i]
10530 );
10531 }
10532 }
10533 }
10534
10535 /// `lam = None` is the objective block alone, and `out` is
10536 /// overwritten (not accumulated) so a reused buffer is safe.
10537 #[test]
10538 fn hessian_vector_product_defaults_and_validation() {
10539 // f = x0^2 + 3 x0 x1 -> ∇²f = [[2, 3], [3, 0]]
10540 let obj = bin(
10541 BinOp::Add,
10542 bin(BinOp::Pow, v(0), c(2.0)),
10543 bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
10544 );
10545 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
10546 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10547 t.get_nlp_info().unwrap();
10548
10549 let mut out = vec![7.0, -7.0]; // dirty buffer
10550 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
10551 .expect("hvp");
10552 assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
10553 assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
10554
10555 // obj_factor scales linearly.
10556 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
10557 .expect("hvp");
10558 assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
10559
10560 // Length mismatches are errors, not panics or silent truncation.
10561 let mut short = vec![0.0; 1];
10562 assert!(
10563 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
10564 .is_err()
10565 );
10566 assert!(
10567 t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
10568 .is_err()
10569 );
10570 assert!(
10571 t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
10572 .is_err()
10573 );
10574 }
10575
10576 /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
10577 /// Hessian — the sparse shape an IPM actually meets. The block HVP has
10578 /// to reproduce it column for column, including the structural zeros:
10579 /// a bug that leaked coupling between non-adjacent variables would
10580 /// show up here and nowhere in a small dense test.
10581 #[test]
10582 fn hessian_vector_products_on_a_sparse_hessian() {
10583 const N: usize = 8;
10584 let mut terms = Vec::new();
10585 for i in 0..N - 1 {
10586 terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
10587 }
10588 for i in 0..N {
10589 terms.push(un(UnaryOp::Exp, v(i)));
10590 }
10591 let prob =
10592 NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
10593 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10594 let info = t.get_nlp_info().unwrap();
10595
10596 // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
10597 assert_eq!(
10598 info.nnz_h_lag as usize,
10599 2 * N - 1,
10600 "chain objective should give a tridiagonal Hessian, not a dense one"
10601 );
10602
10603 let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
10604
10605 // Densify the sparse triangle for the reference.
10606 let nnz = info.nnz_h_lag as usize;
10607 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
10608 assert!(t.eval_h(
10609 None,
10610 false,
10611 1.0,
10612 None,
10613 false,
10614 SparsityRequest::Structure {
10615 irow: &mut irow,
10616 jcol: &mut jcol
10617 }
10618 ));
10619 let mut hvals = vec![0.0; nnz];
10620 assert!(t.eval_h(
10621 Some(&x),
10622 true,
10623 1.0,
10624 None,
10625 true,
10626 SparsityRequest::Values { values: &mut hvals }
10627 ));
10628 let mut dense = vec![vec![0.0; N]; N];
10629 for k in 0..nnz {
10630 let (i, j) = (irow[k] as usize, jcol[k] as usize);
10631 dense[i][j] += hvals[k];
10632 if i != j {
10633 dense[j][i] += hvals[k];
10634 }
10635 }
10636
10637 // One block of N unit seeds recovers the whole matrix — the
10638 // "densify via HVPs" path, in one call.
10639 let mut seeds = vec![0.0; N * N];
10640 for cc in 0..N {
10641 seeds[cc * N + cc] = 1.0;
10642 }
10643 let mut out = vec![0.0; N * N];
10644 t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
10645 .expect("block hvp");
10646 for cc in 0..N {
10647 for i in 0..N {
10648 assert!(
10649 (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
10650 "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
10651 out[cc * N + i],
10652 dense[i][cc]
10653 );
10654 }
10655 }
10656 }
10657
10658 /// The block form must agree with `k` separate single-vector calls
10659 /// (it shares one forward sweep across directions, so a bug there
10660 /// would show as a per-direction discrepancy), and must skip an
10661 /// all-zero direction without disturbing its neighbours.
10662 #[test]
10663 fn hessian_vector_products_match_repeated_single_calls() {
10664 let obj = Expr::Sum(vec![
10665 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
10666 bin(BinOp::Pow, v(2), c(4.0)),
10667 bin(BinOp::Mul, v(0), v(2)),
10668 ]);
10669 let cons = vec![bin(BinOp::Mul, v(1), v(2))];
10670 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
10671 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10672 t.get_nlp_info().unwrap();
10673
10674 let x = [0.4, -0.6, 1.3];
10675 let lam = [0.75];
10676 let cols: [[Number; 3]; 4] = [
10677 [1.0, 2.0, -3.0],
10678 [0.0, 0.0, 0.0], // the skipped direction
10679 [0.5, 0.0, 0.0],
10680 [-1.0, 1.0, 1.0],
10681 ];
10682
10683 let mut block = vec![0.0; 3 * cols.len()];
10684 let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
10685 t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
10686 .expect("block hvp");
10687
10688 for (c, col) in cols.iter().enumerate() {
10689 let mut single = vec![0.0; 3];
10690 t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
10691 .expect("single hvp");
10692 for i in 0..3 {
10693 assert!(
10694 (block[c * 3 + i] - single[i]).abs() < 1e-12,
10695 "direction {c} row {i}: block={:.12e} single={:.12e}",
10696 block[c * 3 + i],
10697 single[i]
10698 );
10699 }
10700 }
10701 // The zero direction really is zero, not stale scratch.
10702 assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
10703 }
10704
10705 /// `k = 0` is a legal empty block, and the length checks scale with
10706 /// `k` rather than assuming a single direction.
10707 #[test]
10708 fn hessian_vector_products_validate_block_shape() {
10709 let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
10710 .expect("build");
10711 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10712 t.get_nlp_info().unwrap();
10713
10714 let mut empty: Vec<Number> = Vec::new();
10715 assert!(
10716 t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
10717 .is_ok()
10718 );
10719
10720 // v sized for one direction while k says two.
10721 let mut out = vec![0.0; 4];
10722 assert!(
10723 t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
10724 .is_err()
10725 );
10726 // out sized for one direction while k says two.
10727 let mut short = vec![0.0; 2];
10728 assert!(
10729 t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
10730 .is_err()
10731 );
10732 }
10733
10734 /// A `maximize` model's objective is negated by the evaluator, and
10735 /// the HVP has to agree with `eval_h` about that — otherwise a
10736 /// Hessian-free step would climb where the sparse path descends.
10737 #[test]
10738 fn hessian_vector_product_respects_maximize_sign() {
10739 let obj = bin(BinOp::Pow, v(0), c(2.0));
10740 let mut p = parts(1, obj, Vec::new());
10741 p.minimize = false;
10742 let prob = NlProblem::from_expressions(p).expect("build");
10743 let mut t = NlTnlp::try_new(prob).expect("tnlp");
10744 t.get_nlp_info().unwrap();
10745
10746 // max x0^2 is minimized as -x0^2, so ∇² = -2.
10747 let mut out = vec![0.0; 1];
10748 t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
10749 .expect("hvp");
10750 assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
10751 }
10752}