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