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 pub lambda0: Vec<Number>,
695 /// AMPL suffix dictionaries. Variable / constraint / objective
696 /// suffixes are stored as dense vectors (length n / m / num_obj)
697 /// with the sparse `.nl` `S`-segment entries scattered in, default
698 /// zero. The integer / real split matches the `S`-segment header's
699 /// kind bit (`0x4` ⇒ real, else integer). See
700 /// <https://ampl.com/REFS/hooking2.pdf> §6 and the upstream `.nl`
701 /// reader in `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`.
702 pub suffixes: NlSuffixes,
703 /// The model's own AMPL option words, taken verbatim from `.nl`
704 /// header line 0 (`g<count> <opt0> <opt1> ...`). A solver echoes
705 /// these back in the `.sol` `Options` block rather than interpreting
706 /// them — see [`crate::sol_writer::format_sol_with_options`]. Empty
707 /// for problems not built from a `.nl` file.
708 pub ampl_options: Vec<i64>,
709 /// The header's nonlinearity census, when the problem came from a `.nl`
710 /// file whose header parsed cleanly. `None` for a model built in memory
711 /// ([`NlProblem::from_expressions`]) — there is no header to read, and
712 /// inventing one would let a consumer trust a count nobody computed.
713 pub nl_counts: Option<NlCounts>,
714 /// AMPL imported (external) functions declared via top-level `F` segments.
715 /// Empty unless the `.nl` file calls compiled-C user functions (typically
716 /// emitted by IDAES property packages — see issue #49).
717 pub imported_funcs: Vec<ImportedFunc>,
718 /// Variable names from the sibling `.col` file, index-aligned to `x`
719 /// (one name per line, column order). Empty when no `.col` file was
720 /// found — AMPL only emits it under `option auxfiles rc;`.
721 ///
722 /// Carrying names lets diagnostics report `flow_balance` / `T_reactor`
723 /// instead of `c[3]` / `x[132]`. Lee et al. (2024) identify the gap
724 /// between detecting an issue and tracing it to a *named* equation as a
725 /// central roadblock for equation-oriented model debugging; threading
726 /// names through to the solver/debugger is the prerequisite for closing
727 /// it. See <https://doi.org/10.69997/sct.147875>.
728 pub var_names: Vec<String>,
729 /// Constraint names from the sibling `.row` file, index-aligned to `g`
730 /// (one name per line, row order). Empty when no `.row` file was found.
731 /// See [`NlProblem::var_names`] for why names are captured.
732 pub con_names: Vec<String>,
733 /// The `.nl` text this problem was parsed from, kept only when some
734 /// body was recognized as a quadratic and therefore has no tree of its
735 /// own. It is what makes [`NlProblem::con_expr`] able to hand back the
736 /// *exact* tree rather than a re-derived one: same bytes, same parser,
737 /// same `Expr`. `None` for [`NlProblem::from_expressions`] models and
738 /// for a parse that recognized nothing.
739 ///
740 /// The text is resident during parsing either way, so keeping it costs
741 /// nothing at the peak this is all in aid of — see
742 /// `dev-notes/quadratic-structure-exploitation.md` §0f.
743 pub src: Option<Arc<String>>,
744 /// The `V`-segment common subexpressions, by CSE-local index. Held so a
745 /// re-parse resolves `v<i>` (`i >= n`) to the *same* `Arc` the original
746 /// parse did — `HybridTape::build_multi` keys sharing on pointer
747 /// identity, so a rebuilt body that allocated fresh bodies would look
748 /// unshared.
749 pub cse_bodies: Vec<Arc<Expr>>,
750}
751
752/// The pieces of a model built in memory, as handed to
753/// [`NlProblem::from_expressions`].
754///
755/// Everything is expressed as [`Expr`] trees — there is no linear/nonlinear
756/// split to fill in, because the AD tape treats a linear term exactly like
757/// any other subexpression (`.nl`'s `J`/`G` segments are a file-format
758/// optimization, not an evaluator requirement). `n` is taken from the length
759/// of `x_l`; `m` from the length of `constraints`.
760///
761/// One cost to that simplification, in *metadata* rather than values: with
762/// `con_linear` empty, `get_constraints_linearity` tags a row `Linear` only
763/// when its expression is literally `Const(0.0)`, so a genuinely linear row
764/// built here reports `NonLinear`. Presolve consumes that tag, and the
765/// direction is the safe one — it loses tightening it could have done, and
766/// never asserts linearity that does not hold — but a frontend that cares
767/// about presolve strength on linear rows should know the tag is
768/// pessimistic on this path.
769#[derive(Debug, Clone)]
770pub struct NlProblemParts {
771 /// `true` to minimize `objective`, `false` to maximize it. Matches
772 /// [`NlProblem::minimize`]: the evaluator negates a maximize objective
773 /// so callers always see the minimization form.
774 pub minimize: bool,
775 /// Objective expression.
776 pub objective: Expr,
777 /// Constant offset added to the objective.
778 pub obj_constant: Number,
779 /// One expression per constraint row; row `i` is bounded by
780 /// `g_l[i] <= constraints[i](x) <= g_u[i]`.
781 pub constraints: Vec<Expr>,
782 /// Variable bounds and starting point, each length `n`. Use `±1e19`
783 /// for "unbounded", the same sentinel the `.nl` reader emits.
784 pub x_l: Vec<Number>,
785 pub x_u: Vec<Number>,
786 pub x0: Vec<Number>,
787 /// Constraint bounds, each length `m`. `g_l[i] == g_u[i]` is an
788 /// equality row.
789 pub g_l: Vec<Number>,
790 pub g_u: Vec<Number>,
791 /// Optional names, index-aligned to `x` / `g`. Empty is fine — every
792 /// consumer falls back to indices (see [`NlProblem::var_names`]).
793 pub var_names: Vec<String>,
794 pub con_names: Vec<String>,
795}
796
797impl NlProblem {
798 /// Assemble a problem from expression trees, with no `.nl` file
799 /// anywhere in the loop (issue #469).
800 ///
801 /// A modeling frontend that already has its own expression DAG should
802 /// come in here rather than serialize to `.nl` and re-parse: the round
803 /// trip is not only slower, it is *lossy*, because `.nl` writers
804 /// routinely refuse operators this tape supports natively (`atan2`,
805 /// `min`/`max`, and — with no `.nl` opcode at all — [`UnaryOp::Erf`]).
806 ///
807 /// The result is an ordinary [`NlProblem`], so it feeds
808 /// [`NlTnlp::try_new`] and gets exactly the evaluators a parsed model
809 /// does: objective, gradient, constraints, Jacobian + structure,
810 /// Lagrangian Hessian + structure, and
811 /// [`NlTnlp::hessian_vector_product`].
812 ///
813 /// Errors on a length mismatch or on a `Var(i)` index at or beyond `n`
814 /// — the latter would otherwise be an out-of-bounds read in the tape's
815 /// forward sweep, so it must be caught while it is still a diagnosable
816 /// user error.
817 pub fn from_expressions(parts: NlProblemParts) -> Result<NlProblem, String> {
818 let NlProblemParts {
819 minimize,
820 objective,
821 obj_constant,
822 constraints,
823 x_l,
824 x_u,
825 x0,
826 g_l,
827 g_u,
828 var_names,
829 con_names,
830 } = parts;
831
832 let n = x_l.len();
833 let m = constraints.len();
834 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
835 if got == want {
836 Ok(())
837 } else {
838 Err(format!(
839 "from_expressions: {name} has length {got}, expected {want}"
840 ))
841 }
842 };
843 check("x_u", x_u.len(), n)?;
844 check("x0", x0.len(), n)?;
845 check("g_l", g_l.len(), m)?;
846 check("g_u", g_u.len(), m)?;
847 if !var_names.is_empty() {
848 check("var_names", var_names.len(), n)?;
849 }
850 if !con_names.is_empty() {
851 check("con_names", con_names.len(), m)?;
852 }
853
854 // Numeric validation, the same screen the `.nl` reader applies
855 // (gh #847). `lower_bound_present` / `upper_bound_present` are
856 // `is_finite() && ...`, so a caller that builds a model here and hands
857 // in a non-finite bound gets it silently dropped -- read as "no bound
858 // declared" -- exactly as a file containing `1e400` was. `-inf` on a
859 // lower side and `+inf` on an upper one are the sentinel said a
860 // different way and are normalized; everything else, `NaN` included,
861 // is refused.
862 let mut x_l = x_l;
863 let mut x_u = x_u;
864 let mut g_l = g_l;
865 let mut g_u = g_u;
866 for (i, v) in x_l.iter_mut().enumerate() {
867 *v = finite_bound_or_err(&format!("x_l[{i}]"), *v, true)
868 .map_err(|e| format!("from_expressions: {e}"))?;
869 }
870 for (i, v) in x_u.iter_mut().enumerate() {
871 *v = finite_bound_or_err(&format!("x_u[{i}]"), *v, false)
872 .map_err(|e| format!("from_expressions: {e}"))?;
873 }
874 for (i, v) in g_l.iter_mut().enumerate() {
875 *v = finite_bound_or_err(&format!("g_l[{i}]"), *v, true)
876 .map_err(|e| format!("from_expressions: {e}"))?;
877 }
878 for (i, v) in g_u.iter_mut().enumerate() {
879 *v = finite_bound_or_err(&format!("g_u[{i}]"), *v, false)
880 .map_err(|e| format!("from_expressions: {e}"))?;
881 }
882 for (i, v) in x0.iter().enumerate() {
883 finite_or_err(&format!("x0[{i}]"), *v).map_err(|e| format!("from_expressions: {e}"))?;
884 }
885 finite_or_err("obj_constant", obj_constant)
886 .map_err(|e| format!("from_expressions: {e}"))?;
887
888 // Structural validation. Memoized on `Cse` pointer identity so a
889 // heavily-shared DAG costs O(nodes) rather than O(inlined tree).
890 let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
891 validate_expr(&objective, n, &mut seen).map_err(|e| format!("objective {e}"))?;
892 for (i, c) in constraints.iter().enumerate() {
893 validate_expr(c, n, &mut seen).map_err(|e| format!("constraint {i} {e}"))?;
894 }
895
896 Ok(NlProblem {
897 n,
898 m,
899 num_obj: 1,
900 minimize,
901 obj_nonlinear: NlBody::Tree(objective),
902 obj_linear: Vec::new(),
903 obj_constant,
904 con_nonlinear: constraints.into_iter().map(NlBody::Tree).collect(),
905 con_linear: vec![Vec::new(); m],
906 x_l,
907 x_u,
908 g_l,
909 g_u,
910 x0,
911 lambda0: vec![0.0; m],
912 suffixes: NlSuffixes::default(),
913 imported_funcs: Vec::new(),
914 ampl_options: Vec::new(),
915 // No header was read, so there is no census to report. Consumers
916 // fall back to walking the trees, which is what they would have
917 // to do here anyway.
918 nl_counts: None,
919 var_names,
920 con_names,
921 // Built from trees, so every body has one and there is nothing
922 // to rebuild from.
923 src: None,
924 cse_bodies: Vec::new(),
925 })
926 }
927
928 /// The objective's nonlinear body as an [`Expr`].
929 ///
930 /// Borrowed when the tree is resident, rebuilt when the parser
931 /// recognized the body and skipped building it. The rebuild re-parses
932 /// the body's own bytes with the same parser that produced the rest of
933 /// the model, so the result is the tree a non-recognizing parse would
934 /// have produced — structurally identical, coefficient bit patterns
935 /// included, and sharing the same `Cse` allocations.
936 ///
937 /// It is not free: it allocates the nodes the recognizer avoided. Reach
938 /// for [`NlBody::quad`] first if the coefficients are what you want.
939 pub fn obj_expr(&self) -> std::borrow::Cow<'_, Expr> {
940 self.body_expr(&self.obj_nonlinear, "objective")
941 }
942
943 /// Row `k`'s nonlinear body as an [`Expr`]. See [`Self::obj_expr`].
944 ///
945 /// # Panics
946 ///
947 /// Panics if `k >= m`, or if re-parsing a recognized body fails — the
948 /// latter cannot happen for a problem this crate produced (the bytes
949 /// parsed once already, by this code) and means the `NlProblem` was
950 /// assembled by hand with a `src` that does not match its bodies.
951 pub fn con_expr(&self, k: usize) -> std::borrow::Cow<'_, Expr> {
952 self.body_expr(&self.con_nonlinear[k], "constraint")
953 }
954
955 fn body_expr<'a>(&'a self, body: &'a NlBody, what: &str) -> std::borrow::Cow<'a, Expr> {
956 match body {
957 NlBody::Tree(e) => std::borrow::Cow::Borrowed(e),
958 NlBody::Quad(q) => {
959 let src = self
960 .src
961 .as_deref()
962 .unwrap_or_else(|| panic!("{what} body was recognized but no source is kept"));
963 std::borrow::Cow::Owned(
964 parse_body_fragment(&src[q.src.clone()], self.n, &self.cse_bodies)
965 .unwrap_or_else(|e| panic!("re-parsing a recognized {what} body: {e}")),
966 )
967 }
968 }
969 }
970}
971
972/// Structural check on an expression bound for [`NlProblem::from_expressions`]:
973/// every `Var(i)` must satisfy `i < n`, and no `Expr::Funcall` may appear.
974///
975/// Both are things the tape cannot recover from later. An out-of-range
976/// `Var` is an out-of-bounds read in the forward sweep. A `Funcall` is
977/// worse-looking than it is fatal: `from_expressions` has nowhere to put
978/// the `F`-segment declarations an AMPL imported function needs
979/// (`NlProblemParts` has no field for them, and the built problem's
980/// `imported_funcs` is necessarily empty), so *any* funcall on this path is
981/// unresolvable. Accepting it would surface as "AMPLFUNC is not set" —
982/// advice the user cannot act on, because setting `AMPLFUNC` just moves the
983/// failure to "funcall id N has no F<N> declaration". Rejecting it here
984/// says the true thing: this door does not carry external functions; go
985/// through `read_nl` / `parse_nl_text` for a model that needs them.
986///
987/// `seen` memoizes `Cse` bodies by pointer identity across calls, so
988/// passing one set through a whole problem keeps the walk linear in
989/// distinct DAG nodes rather than exponential in sharing depth. Skipping a
990/// repeat visit is sound because the caller aborts on the first violation:
991/// reaching a body a second time proves the first visit found none.
992fn validate_expr(
993 e: &Expr,
994 n: usize,
995 seen: &mut std::collections::HashSet<*const Expr>,
996) -> Result<(), String> {
997 match e {
998 Expr::Const(_) => Ok(()),
999 Expr::Var(i) => {
1000 if *i < n {
1001 Ok(())
1002 } else {
1003 Err(format!("references Var({i}) but n = {n}"))
1004 }
1005 }
1006 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
1007 validate_expr(a, n, seen)?;
1008 validate_expr(b, n, seen)
1009 }
1010 Expr::Unary(_, a) | Expr::Not(a) => validate_expr(a, n, seen),
1011 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
1012 for a in args {
1013 validate_expr(a, n, seen)?;
1014 }
1015 Ok(())
1016 }
1017 Expr::Cond { cond, then_, else_ } => {
1018 validate_expr(cond, n, seen)?;
1019 validate_expr(then_, n, seen)?;
1020 validate_expr(else_, n, seen)
1021 }
1022 Expr::Cse(body) => {
1023 if seen.insert(Arc::as_ptr(body)) {
1024 validate_expr(body, n, seen)
1025 } else {
1026 Ok(())
1027 }
1028 }
1029 Expr::Funcall { id, .. } => Err(format!(
1030 "references AMPL imported function id {id}, which this path cannot \
1031 resolve: a problem built from expressions has no F-segment \
1032 declarations to bind it to. Load such a model with read_nl or \
1033 parse_nl_text instead."
1034 )),
1035 }
1036}
1037
1038/// Suffix data parsed out of `S`-segments. Sparse entries are scattered
1039/// into dense vectors at problem load time so callers can index by
1040/// variable / constraint number directly. Empty maps when the `.nl`
1041/// file declared no suffixes.
1042#[derive(Debug, Clone, Default)]
1043pub struct NlSuffixes {
1044 /// Variable-level integer suffixes (kind = 0). Each vector has
1045 /// length `n_full` (problem variables).
1046 pub var_int: BTreeMap<String, Vec<Index>>,
1047 /// Constraint-level integer suffixes (kind = 1). Length `m_full`.
1048 pub con_int: BTreeMap<String, Vec<Index>>,
1049 /// Objective-level integer suffixes (kind = 2). Length `num_obj`.
1050 pub obj_int: BTreeMap<String, Vec<Index>>,
1051 /// Problem-level integer suffixes (kind = 3). Single value per name.
1052 pub problem_int: BTreeMap<String, Index>,
1053 /// Variable-level real suffixes (kind = 4). Length `n_full`.
1054 pub var_real: BTreeMap<String, Vec<Number>>,
1055 /// Constraint-level real suffixes (kind = 5). Length `m_full`.
1056 pub con_real: BTreeMap<String, Vec<Number>>,
1057 /// Objective-level real suffixes (kind = 6). Length `num_obj`.
1058 pub obj_real: BTreeMap<String, Vec<Number>>,
1059 /// Problem-level real suffixes (kind = 7). Single value per name.
1060 pub problem_real: BTreeMap<String, Number>,
1061}
1062
1063/// Parse an `.nl` file from disk.
1064///
1065/// After parsing the `.nl` body, this also looks for AMPL's optional
1066/// sibling name files — `stub.col` (variable names) and `stub.row`
1067/// (constraint names), emitted only when the modeler sets
1068/// `option auxfiles rc;`. When present and well-formed they populate
1069/// [`NlProblem::var_names`] / [`NlProblem::con_names`]; when absent or
1070/// malformed the names stay empty and every downstream consumer falls
1071/// back to indices. Names are a diagnostic nicety, never load-blocking
1072/// (cf. Lee et al. 2024, <https://doi.org/10.69997/sct.147875>).
1073pub fn read_nl_file(path: &Path) -> Result<NlProblem, String> {
1074 // AMPL invokes a solver with an extensionless *stub* — e.g.
1075 // `pounce mymodel -AMPL` — and expects `mymodel.nl` to be read (and
1076 // the `.col`/`.row`/`.sol` siblings named off the same stem). If the
1077 // path as given is missing but appending `.nl` names an existing file,
1078 // resolve to that. This only ever *adds* a fallback: an existing path
1079 // is read verbatim, so nothing changes for callers that already pass a
1080 // full `.nl` path (Pyomo, `--nl-file`, the second-positional form).
1081 let resolved = if path.exists() {
1082 path.to_path_buf()
1083 } else {
1084 let with_nl = append_extension(path, "nl");
1085 if with_nl.exists() {
1086 with_nl
1087 } else {
1088 path.to_path_buf()
1089 }
1090 };
1091 let txt = std::fs::read_to_string(&resolved)
1092 .map_err(|e| format!("could not read {}: {}", resolved.display(), e))?;
1093 // By value: a recognized body keeps a byte range into this text rather
1094 // than a tree, so it is moved into the problem instead of copied.
1095 let mut prob = parse_nl_string(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())?;
1096 prob.var_names = read_name_file(&resolved.with_extension("col"), prob.n);
1097 prob.con_names = read_name_file(&resolved.with_extension("row"), prob.m);
1098 Ok(prob)
1099}
1100
1101/// Append `.ext` to `path`'s full file name (AMPL stub convention:
1102/// `mymodel` → `mymodel.nl`), as opposed to [`Path::with_extension`],
1103/// which would *replace* an existing extension. A stub that itself
1104/// contains a dot (`my.model` → `my.model.nl`) is therefore handled the
1105/// way AMPL names it.
1106fn append_extension(path: &Path, ext: &str) -> std::path::PathBuf {
1107 let mut name = path.as_os_str().to_os_string();
1108 name.push(".");
1109 name.push(ext);
1110 std::path::PathBuf::from(name)
1111}
1112
1113/// Read an AMPL name file (`.col` / `.row`): one name per line, in index
1114/// order. Returns the first `expected` names, or an empty vector when the
1115/// file is missing, unreadable, or has fewer than `expected` lines.
1116///
1117/// Returning empty (rather than erroring) on any mismatch is deliberate:
1118/// names are an optional diagnostic aid, so a missing or truncated file
1119/// must never block a solve. The `.take(expected)` also drops AMPL's
1120/// convention of appending the objective name after the constraint names
1121/// in `.row`, keeping the result aligned 1:1 with `g`.
1122fn read_name_file(path: &Path, expected: usize) -> Vec<String> {
1123 let Ok(txt) = std::fs::read_to_string(path) else {
1124 return Vec::new();
1125 };
1126 let names: Vec<String> = txt.lines().take(expected).map(str::to_owned).collect();
1127 if names.len() == expected {
1128 names
1129 } else {
1130 Vec::new()
1131 }
1132}
1133
1134/// The value of a constraint's nonlinear part when that part is a
1135/// constant, else `None`. Drives the constant-row-body fold in
1136/// [`parse_nl_text`].
1137///
1138/// "Constant" is decided by *evaluation*, not by syntax: a literal
1139/// `Expr::Const` is the common case, but `o0 n1 n2` is just as constant
1140/// and is folded too.
1141///
1142/// Declines in two cases, both of which would make the fold unsound:
1143/// * The expression calls an AMPL imported function. Its value depends on
1144/// a shared library resolved much later (`nl_external::ExternalResolver`),
1145/// so it is not a parse-time constant even with constant arguments — and
1146/// [`eval_expr`] panics on `Funcall` rather than guess.
1147/// * The value is not finite (`n0 / n0`, `log(-1)`, an overflow). Pushing
1148/// a NaN or infinity into a bound would corrupt a row that is merely
1149/// infeasible; leaving the expression in place keeps it a solver-time
1150/// fact.
1151fn row_constant_value(e: &Expr) -> Option<Number> {
1152 // The identity zero the parser preallocates for every untouched row is
1153 // by far the most common input; settle it without walking anything.
1154 if let Expr::Const(c) = e {
1155 return c.is_finite().then_some(*c);
1156 }
1157 let mut vars: BTreeSet<usize> = BTreeSet::new();
1158 collect_vars(e, &mut vars);
1159 if !vars.is_empty() {
1160 return None;
1161 }
1162 let mut funcs: BTreeSet<usize> = BTreeSet::new();
1163 crate::nl_external::collect_funcall_ids(e, &mut funcs);
1164 if !funcs.is_empty() {
1165 return None;
1166 }
1167 // Variable-free, so no `Expr::Var` can index into the (empty) point.
1168 let v = eval_expr(e, &[]);
1169 v.is_finite().then_some(v)
1170}
1171
1172/// Parse `.nl` text content. Public so tests can use string literals.
1173///
1174/// Degree-2 bodies are recognized as the token stream is read, so their
1175/// `Expr` trees are never built (gh #588, Q5); `POUNCE_DBG_NO_QUAD=1`
1176/// turns that off and restores the pre-Q5 parse exactly, which is what the
1177/// A/B reference and gh #540's guard test need. See
1178/// [`parse_nl_text_with_quadratic`] for the same switch as a parameter.
1179pub fn parse_nl_text(txt: &str) -> Result<NlProblem, String> {
1180 parse_nl_text_with_quadratic(txt, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
1181}
1182
1183/// [`parse_nl_text`] with parse-time quadratic recognition explicitly on
1184/// or off.
1185///
1186/// The env var the plain entry point reads is process-global, which is
1187/// exactly wrong for a differential test that has to parse the *same* text
1188/// both ways in one process and compare the results — so the knob is a
1189/// parameter here, mirroring [`NlTnlp::try_new_with_quadratic`].
1190pub fn parse_nl_text_with_quadratic(txt: &str, use_quadratic: bool) -> Result<NlProblem, String> {
1191 parse_nl_string(txt.to_string(), use_quadratic)
1192}
1193
1194/// [`parse_nl_text_with_quadratic`], taking the text by value.
1195///
1196/// A recognized body keeps a byte range into the source rather than a tree,
1197/// so the source outlives the parse. Taking ownership means the file
1198/// [`read_nl_file`] already read is *moved* into the problem instead of
1199/// copied: the text is resident during parsing either way, so this costs
1200/// nothing at the peak the phase is about.
1201pub fn parse_nl_string(txt: String, use_quadratic: bool) -> Result<NlProblem, String> {
1202 let src = Arc::new(txt);
1203 let mut p = Parser::new(&src, use_quadratic);
1204 p.parse_header()?;
1205 let n = p.n;
1206 let m = p.m;
1207 let num_obj = p.num_obj;
1208
1209 let mut con_nonlinear: Vec<NlBody> = (0..m).map(|_| NlBody::Tree(Expr::Const(0.0))).collect();
1210 let mut obj_nonlinear = NlBody::Tree(Expr::Const(0.0));
1211 let mut minimize = true;
1212 let mut obj_linear: Vec<(usize, Number)> = Vec::new();
1213 let mut con_linear: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m];
1214 let mut x_l = vec![-1e19; n];
1215 let mut x_u = vec![1e19; n];
1216 let mut g_l = vec![-1e19; m];
1217 let mut g_u = vec![1e19; m];
1218 let mut x0 = vec![0.0; n];
1219 let mut lambda0 = vec![0.0; m];
1220 let mut suffixes = NlSuffixes::default();
1221 let mut imported_funcs: Vec<ImportedFunc> = Vec::new();
1222 // Segment presence, for the truncation check after the loop (gh#785).
1223 let mut saw_r = false;
1224 let mut saw_b = false;
1225
1226 while let Some(line) = p.peek_segment_line() {
1227 let tag = line
1228 .trim_start()
1229 .chars()
1230 .next()
1231 .ok_or("unexpected blank segment header")?;
1232 match tag {
1233 'C' => {
1234 let (_hdr, rest) = p.eat_segment_header()?;
1235 let _ = rest;
1236 let idx = parse_segment_index(_hdr, 'C')?;
1237 if idx >= m {
1238 return Err(format!("C{idx} out of range; m={m}"));
1239 }
1240 con_nonlinear[idx] = p.parse_body()?;
1241 }
1242 'O' => {
1243 let (hdr, _rest) = p.eat_segment_header()?;
1244 let parts: Vec<&str> = hdr.split_whitespace().collect();
1245 if parts.len() < 2 {
1246 return Err(format!("malformed O-segment header: {hdr}"));
1247 }
1248 let idx = parse_segment_index(parts[0], 'O')?;
1249 let kind: i32 = parts[1].parse().map_err(|e| format!("O kind: {e}"))?;
1250 if idx == 0 {
1251 minimize = kind == 0;
1252 obj_nonlinear = p.parse_body()?;
1253 } else {
1254 // Extra objectives are read but ignored.
1255 let _ = p.parse_expr()?;
1256 }
1257 }
1258 'r' => {
1259 p.eat_segment_header()?;
1260 saw_r = true;
1261 for i in 0..m {
1262 let line = p.next_data_line()?;
1263 let (lo, hi) = parse_bound_line(line)?;
1264 g_l[i] = lo;
1265 g_u[i] = hi;
1266 }
1267 }
1268 'b' => {
1269 p.eat_segment_header()?;
1270 saw_b = true;
1271 for i in 0..n {
1272 let line = p.next_data_line()?;
1273 let (lo, hi) = parse_bound_line(line)?;
1274 x_l[i] = lo;
1275 x_u[i] = hi;
1276 }
1277 }
1278 'k' => {
1279 // Column counts in the Jacobian; we don't need their
1280 // values for evaluation (the J segments give explicit
1281 // lists), but we must consume exactly as many data lines
1282 // as follow or the segment stream desyncs. The `.nl`
1283 // format writes that line count in the header itself
1284 // (`k<count>`), and the standard value is `n-1`. Read the
1285 // declared count rather than assuming it: a file with a
1286 // nonstandard count would otherwise leave us reading the
1287 // wrong number of lines, swallowing a later segment header
1288 // (or stopping short) and failing with a confusing,
1289 // far-removed error. Validate against the expected `n-1`
1290 // so a mismatch surfaces here, clearly, at its source.
1291 let (hdr, _) = p.eat_segment_header()?;
1292 let declared = parse_segment_index(hdr, 'k')?;
1293 let expected = if n == 0 { 0 } else { n - 1 };
1294 if declared != expected {
1295 return Err(format!(
1296 "k-segment declares {declared} column-count lines but \
1297 the standard count for n={n} variables is {expected}"
1298 ));
1299 }
1300 for _ in 0..declared {
1301 p.next_data_line()?;
1302 }
1303 }
1304 'J' => {
1305 let (hdr, _) = p.eat_segment_header()?;
1306 let parts: Vec<&str> = hdr.split_whitespace().collect();
1307 if parts.len() < 2 {
1308 return Err(format!("malformed J-segment header: {hdr}"));
1309 }
1310 let row = parse_segment_index(parts[0], 'J')?;
1311 let nz: usize = parts[1].parse().map_err(|e| format!("J nz: {e}"))?;
1312 if row >= m {
1313 return Err(format!("J{row} out of range"));
1314 }
1315 for _ in 0..nz {
1316 let line = p.next_data_line()?;
1317 let (var, coef) = parse_var_coef(line)?;
1318 // Validate the column index here: an out-of-range `var`
1319 // would otherwise be stored and panic as a slice OOB
1320 // (`x[var]`) during constraint evaluation. Mirror the
1321 // clean parse error used for the row index above.
1322 if var >= n {
1323 return Err(format!(
1324 "J{row} entry variable index {var} out of range (n={n})"
1325 ));
1326 }
1327 con_linear[row].push((var, coef));
1328 }
1329 }
1330 'G' => {
1331 let (hdr, _) = p.eat_segment_header()?;
1332 let parts: Vec<&str> = hdr.split_whitespace().collect();
1333 if parts.len() < 2 {
1334 return Err(format!("malformed G-segment header: {hdr}"));
1335 }
1336 let idx = parse_segment_index(parts[0], 'G')?;
1337 let nz: usize = parts[1].parse().map_err(|e| format!("G nz: {e}"))?;
1338 let mut acc = Vec::with_capacity(nz);
1339 for _ in 0..nz {
1340 let line = p.next_data_line()?;
1341 let (var, coef) = parse_var_coef(line)?;
1342 // Same as J: reject an out-of-range gradient column index
1343 // up front rather than letting it panic on `x[var]` later.
1344 if var >= n {
1345 return Err(format!(
1346 "G{idx} entry variable index {var} out of range (n={n})"
1347 ));
1348 }
1349 acc.push((var, coef));
1350 }
1351 if idx == 0 {
1352 obj_linear = acc;
1353 }
1354 }
1355 'x' => {
1356 let (hdr, _) = p.eat_segment_header()?;
1357 let parts: Vec<&str> = hdr.split_whitespace().collect();
1358 let nx: usize = parts
1359 .first()
1360 .and_then(|s| s.trim_start_matches('x').parse().ok())
1361 .ok_or_else(|| format!("malformed x-segment header: {hdr}"))?;
1362 for _ in 0..nx {
1363 let line = p.next_data_line()?;
1364 let (idx, val) = parse_var_coef(line)?;
1365 // Reject out-of-range indices as a parse error, matching
1366 // J/G strictness, rather than silently dropping the entry
1367 // (which hides a corrupt initial-primal segment).
1368 if idx >= n {
1369 return Err(format!(
1370 "x-segment variable index {idx} out of range (n={n})"
1371 ));
1372 }
1373 x0[idx] = val;
1374 }
1375 }
1376 'd' => {
1377 let (hdr, _) = p.eat_segment_header()?;
1378 let parts: Vec<&str> = hdr.split_whitespace().collect();
1379 let nd: usize = parts
1380 .first()
1381 .and_then(|s| s.trim_start_matches('d').parse().ok())
1382 .ok_or_else(|| format!("malformed d-segment header: {hdr}"))?;
1383 for _ in 0..nd {
1384 let line = p.next_data_line()?;
1385 let (idx, val) = parse_var_coef(line)?;
1386 // Reject out-of-range indices as a parse error, matching
1387 // J/G strictness, rather than silently dropping the entry
1388 // (which hides a corrupt initial-dual segment).
1389 if idx >= m {
1390 return Err(format!(
1391 "d-segment constraint index {idx} out of range (m={m})"
1392 ));
1393 }
1394 lambda0[idx] = val;
1395 }
1396 }
1397 'V' => p.parse_v_segment()?,
1398 'S' => {
1399 parse_suffix_segment(&mut p, n, m, num_obj, &mut suffixes)?;
1400 }
1401 'F' => {
1402 // AMPL imported (external) function declaration:
1403 // `F<k> <type> <nargs> <name>`.
1404 let (hdr, _rest) = p.eat_segment_header()?;
1405 let parts: Vec<&str> = hdr.split_whitespace().collect();
1406 if parts.is_empty() {
1407 return Err(format!("malformed F-segment header: '{hdr}'"));
1408 }
1409 let id = parse_segment_index(parts[0], 'F')?;
1410 let kind: usize = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1411 let nargs: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1412 let name = parts.get(3).copied().unwrap_or("").to_string();
1413 imported_funcs.push(ImportedFunc {
1414 id,
1415 kind,
1416 nargs,
1417 name,
1418 });
1419 }
1420 other => return Err(format!("unknown .nl segment tag '{other}'")),
1421 }
1422 }
1423
1424 // A `.nl` file that ends early is not a smaller model — it is a corrupt
1425 // one, and every segment the truncation ate is a piece of the problem
1426 // that silently reverts to a default: a dropped `r` leaves every row at
1427 // ±1e19 (i.e. unconstrained), a dropped `b` leaves every variable free,
1428 // a dropped `J` leaves every row's linear part empty. The segment loop
1429 // cannot tell those defaults from a legitimately absent segment, so the
1430 // truncated model solves, and reports `SolveSucceeded` with a
1431 // confidently wrong objective — which is strictly worse than the parse
1432 // error every other malformed input already gets (gh#785). The header
1433 // declares enough to tell the two apart; check it here, once, against
1434 // what the segments actually delivered.
1435 //
1436 // `r` and `b` are unconditional in the format: AMPL writes both whenever
1437 // the model has rows / columns at all, free rows and free variables
1438 // included — those get the `3` ("no bounds") code, not an omitted line.
1439 if m > 0 && !saw_r {
1440 return Err(format!(
1441 "missing `r` (constraint-bounds) segment for a model declaring {m} \
1442 constraint(s): the .nl file is truncated or corrupt"
1443 ));
1444 }
1445 if n > 0 && !saw_b {
1446 return Err(format!(
1447 "missing `b` (variable-bounds) segment for a model declaring {n} \
1448 variable(s): the .nl file is truncated or corrupt"
1449 ));
1450 }
1451 // The `J` segments must deliver exactly the Jacobian nonzero count the
1452 // header declares — `nzc` is the sum of their lengths by construction,
1453 // nonlinear-only columns included (they are written with a zero
1454 // coefficient, not omitted). This is the check that catches a truncation
1455 // landing *after* `r` and `b`, where the bounds are all present and only
1456 // the coefficients are gone.
1457 if let Some(declared) = p.declared_jac_nnz {
1458 let parsed: usize = con_linear.iter().map(Vec::len).sum();
1459 if parsed != declared {
1460 return Err(format!(
1461 "header declares {declared} Jacobian nonzero(s) but the J \
1462 segments supply {parsed}: the .nl file is truncated or corrupt"
1463 ));
1464 }
1465 }
1466
1467 // Normalize constant row bodies into the row bounds, so that
1468 // "the nonlinear part is the identity zero" and "this row's body is
1469 // Σaⱼxⱼ" mean the same thing for every downstream consumer.
1470 //
1471 // A `C<i>` segment holding a variable-free expression (`n3.0`, or
1472 // anything that evaluates to a constant such as `o0 n1 n2`) is an
1473 // affine row — but it arrives with a non-zero `con_nonlinear[i]`,
1474 // which every consumer reads as "nonlinear": the linearity predicate
1475 // (`get_constraints_linearity`), which is what makes presolve's
1476 // linear-equality reduction decline the row; the CLI problem
1477 // classifier (`is_trivially_zero` in `dispatch.rs`, whose fallback
1478 // polynomial walk absorbs a bare literal but not a constant it has to
1479 // compute — so an otherwise plain LP carrying a `sqrt(9)` classified
1480 // NLP and never reached the convex path); and the FBBT translator.
1481 // Folding here fixes all of them at once, with no per-consumer audit.
1482 //
1483 // The shift is exact and invisible from outside: the body drops by `c`
1484 // and each bound drops by `c` with it, so the feasible set, the active
1485 // set, and the duals are unchanged (`gh #492`). This is the same
1486 // normalization `qp_extract::analyze_quadratic_full` already performs
1487 // ad hoc via `const_shift`, promoted to the parse boundary.
1488 for i in 0..m {
1489 // A recognized body is degree 2, so it is not a constant row and
1490 // this fold has nothing to do with it.
1491 let Some(tree) = con_nonlinear[i].tree() else {
1492 continue;
1493 };
1494 let Some(c) = row_constant_value(tree) else {
1495 continue;
1496 };
1497 // Presence is directional (gh #401): shifting an *absent* bound
1498 // would turn the ±1e19 sentinel into a real bound for `c < 0`
1499 // (lower) or `c > 0` (upper), inventing a constraint. Leave the
1500 // sentinels alone.
1501 if lower_bound_present(g_l[i]) {
1502 g_l[i] -= c;
1503 }
1504 if upper_bound_present(g_u[i]) {
1505 g_u[i] -= c;
1506 }
1507 con_nonlinear[i] = NlBody::Tree(Expr::Const(0.0));
1508 }
1509
1510 // The source is kept only when something in it has no tree of its own.
1511 // A model with nothing recognized is byte-for-byte the pre-Q5 problem,
1512 // extra field included.
1513 let any_recognized =
1514 obj_nonlinear.quad().is_some() || con_nonlinear.iter().any(|b| b.quad().is_some());
1515 let kept_src = any_recognized.then(|| Arc::clone(&src));
1516
1517 Ok(NlProblem {
1518 n,
1519 m,
1520 num_obj,
1521 minimize,
1522 obj_nonlinear,
1523 obj_linear,
1524 obj_constant: 0.0,
1525 con_nonlinear,
1526 con_linear,
1527 x_l,
1528 x_u,
1529 g_l,
1530 g_u,
1531 x0,
1532 lambda0,
1533 suffixes,
1534 ampl_options: p.ampl_options.clone(),
1535 nl_counts: p.nl_counts,
1536 imported_funcs,
1537 // `.nl` text carries no names; `read_nl_file` fills these from the
1538 // sibling `.col`/`.row` files when present.
1539 var_names: Vec::new(),
1540 con_names: Vec::new(),
1541 src: kept_src,
1542 cse_bodies: p.cses.clone(),
1543 })
1544}
1545
1546/// Parse a single `S`-segment. Format (Gay 2005, "Hooking Your Solver
1547/// to AMPL", §6, and `ref/Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`):
1548///
1549/// ```text
1550/// S<kind> <nentries> <suffix_name>
1551/// <idx> <value> ... nentries lines
1552/// ```
1553///
1554/// `<kind>` is a 3-bit encoding:
1555/// * Bits 0-1 select the suffix target: 0 = variables, 1 = constraints,
1556/// 2 = objectives, 3 = problem-level.
1557/// * Bit 2 (`0x4`) selects the value type: 0 = integer, 1 = real.
1558///
1559/// Sparse entries scatter into a freshly-allocated dense vector (zero
1560/// default), sized for the target dimension. Problem-level suffixes
1561/// (kind = 3 / 7) carry a single value.
1562fn parse_suffix_segment(
1563 p: &mut Parser,
1564 n: usize,
1565 m: usize,
1566 num_obj: usize,
1567 out: &mut NlSuffixes,
1568) -> Result<(), String> {
1569 let (hdr, _) = p.eat_segment_header()?;
1570 let parts: Vec<&str> = hdr.split_whitespace().collect();
1571 if parts.len() < 3 {
1572 return Err(format!(
1573 "malformed S-segment header: '{hdr}' (expected `S<kind> <n> <name>`)"
1574 ));
1575 }
1576 let kind_str = parts[0].trim_start_matches('S');
1577 let kind: u32 = kind_str
1578 .parse()
1579 .map_err(|e| format!("S kind '{kind_str}': {e}"))?;
1580 let nentries: usize = parts[1].parse().map_err(|e| format!("S nentries: {e}"))?;
1581 let name = parts[2].to_string();
1582
1583 let is_real = (kind & 0x4) != 0;
1584 let target = kind & 0x3;
1585 let target_dim = match target {
1586 0 => n,
1587 1 => m,
1588 2 => num_obj,
1589 3 => 0, // problem-level — entries are single-valued (idx=0)
1590 _ => unreachable!("kind & 0x3 is in 0..=3"),
1591 };
1592
1593 // Pre-allocate dense buffers (default zero). Problem-level kinds
1594 // (3 / 7) hold a single scalar — we still read the (idx, value)
1595 // pairs but only the value field is meaningful.
1596 let mut int_buf: Vec<Index> = if !is_real && target != 3 {
1597 vec![0; target_dim]
1598 } else {
1599 Vec::new()
1600 };
1601 let mut real_buf: Vec<Number> = if is_real && target != 3 {
1602 vec![0.0; target_dim]
1603 } else {
1604 Vec::new()
1605 };
1606 let mut problem_int: Index = 0;
1607 let mut problem_real: Number = 0.0;
1608
1609 for _ in 0..nentries {
1610 let line = p.next_data_line()?;
1611 let parts: Vec<&str> = line.split_whitespace().collect();
1612 if parts.len() < 2 {
1613 return Err(format!(
1614 "malformed S-segment entry '{line}' (expected `<idx> <value>`)"
1615 ));
1616 }
1617 let idx: usize = parts[0]
1618 .parse()
1619 .map_err(|e| format!("S entry idx '{}': {e}", parts[0]))?;
1620 if target != 3 && idx >= target_dim {
1621 return Err(format!(
1622 "S-suffix '{name}' index {idx} out of range for target dim {target_dim}"
1623 ));
1624 }
1625 if is_real {
1626 let v: Number = parts[1]
1627 .parse()
1628 .map_err(|e| format!("S real entry value '{}': {e}", parts[1]))?;
1629 if target == 3 {
1630 problem_real = v;
1631 } else {
1632 real_buf[idx] = v;
1633 }
1634 } else {
1635 let v: Index = parts[1]
1636 .parse()
1637 .map_err(|e| format!("S int entry value '{}': {e}", parts[1]))?;
1638 if target == 3 {
1639 problem_int = v;
1640 } else {
1641 int_buf[idx] = v;
1642 }
1643 }
1644 }
1645
1646 match (target, is_real) {
1647 (0, false) => {
1648 out.var_int.insert(name, int_buf);
1649 }
1650 (1, false) => {
1651 out.con_int.insert(name, int_buf);
1652 }
1653 (2, false) => {
1654 out.obj_int.insert(name, int_buf);
1655 }
1656 (3, false) => {
1657 out.problem_int.insert(name, problem_int);
1658 }
1659 (0, true) => {
1660 out.var_real.insert(name, real_buf);
1661 }
1662 (1, true) => {
1663 out.con_real.insert(name, real_buf);
1664 }
1665 (2, true) => {
1666 out.obj_real.insert(name, real_buf);
1667 }
1668 (3, true) => {
1669 out.problem_real.insert(name, problem_real);
1670 }
1671 _ => unreachable!(),
1672 }
1673 Ok(())
1674}
1675
1676fn parse_segment_index(s: &str, tag: char) -> Result<usize, String> {
1677 let trimmed = s.trim_start_matches(tag);
1678 trimmed
1679 .parse()
1680 .map_err(|e| format!("malformed {tag}-segment index '{s}': {e}"))
1681}
1682
1683// `parse_bound_line` and `parse_var_coef` run once per `r` / `b` / `J` /
1684// `G` / `x` / `d` data line, so between them they see every Jacobian and
1685// gradient nonzero in the file. Both walk the whitespace iterator
1686// directly instead of collecting a `Vec<&str>` first — that collect was
1687// a heap allocation per line on top of the one the reader used to make
1688// handing the line over.
1689/// Refuse a non-finite number read out of a `.nl` file (gh #847).
1690///
1691/// `str::parse::<f64>()` accepts `inf`, `-inf` and `nan`, and it also *returns*
1692/// `inf` for any literal that overflows the type — `1e400` is a plausible thing
1693/// for a model generator to write. Nothing downstream treats such a value as an
1694/// error, and in one place it is actively misread: `lower_bound_present` /
1695/// `upper_bound_present` are `is_finite() && ...`, so a non-finite bound is
1696/// indistinguishable from a bound that was never declared, and is silently
1697/// dropped. On a model that a lower bound of `1e300` makes infeasible, the same
1698/// bound written `1e400` returned `EXIT: Optimal Solution Found.` with exit code
1699/// 0. A `nan` is worse: it propagates into the answer, and the solve reports
1700/// `Objective: nan` under `Solve_Succeeded`.
1701///
1702/// Ipopt refuses this input ("Invalid number"), POUNCE's own NLP arm refuses it,
1703/// and `pounce.solve_qp` refuses a non-finite bound with a bespoke `ValueError`.
1704/// There is no reading on which `Optimal` is the intended answer, so the reader
1705/// refuses it too.
1706fn finite_or_err(what: &str, v: Number) -> Result<Number, String> {
1707 if v.is_finite() {
1708 Ok(v)
1709 } else {
1710 Err(format!(
1711 "invalid number: {what} is {v}, which is not finite"
1712 ))
1713 }
1714}
1715
1716/// The same screen for a *bound* slot, where one non-finite value has an
1717/// unambiguous meaning and is normalized instead of refused.
1718///
1719/// `.nl` states "no bound" with a bound **kind** (1 = upper only, 2 = lower
1720/// only, 3 = free), so a non-finite number in a bound slot is a corrupt value
1721/// rather than a notation — with one exception per side. `-inf` in a *lower*
1722/// slot and `+inf` in an *upper* slot say precisely what the `±1e19` sentinel
1723/// says, and a writer that emits them means it, so they map to the sentinel.
1724///
1725/// Everything else is refused, and the asymmetry is the whole point: `+inf` as
1726/// a *lower* bound is the gh #847 case. It is not "unbounded below" — it is an
1727/// empty box, and reading it as "absent" is what turned an infeasible model
1728/// into an `Optimal` one. `NaN` is refused on either side, having no meaning at
1729/// all.
1730fn finite_bound_or_err(what: &str, v: Number, lower: bool) -> Result<Number, String> {
1731 if v.is_finite() {
1732 return Ok(v);
1733 }
1734 if lower && v == Number::NEG_INFINITY {
1735 return Ok(-1e19);
1736 }
1737 if !lower && v == Number::INFINITY {
1738 return Ok(1e19);
1739 }
1740 Err(format!(
1741 "invalid number: {what} is {v}, which is not finite (a `.nl` file \
1742 states an absent bound with a bound kind of 1, 2 or 3, not with a \
1743 non-finite value)"
1744 ))
1745}
1746
1747fn parse_bound_line(line: &str) -> Result<(Number, Number), String> {
1748 let mut parts = line.split_whitespace();
1749 let kind: i32 = parts
1750 .next()
1751 .ok_or("empty bound line")?
1752 .parse()
1753 .map_err(|e| format!("bound kind: {e}"))?;
1754 let lo;
1755 let hi;
1756 match kind {
1757 0 => {
1758 // 0 lo hi
1759 let (l, h) = (parts.next(), parts.next());
1760 let (Some(l), Some(h)) = (l, h) else {
1761 return Err(format!("bound kind 0 needs 2 values: '{line}'"));
1762 };
1763 lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1764 hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1765 }
1766 1 => {
1767 // 1 hi
1768 let Some(h) = parts.next() else {
1769 return Err(format!("bound kind 1 needs 1 value: '{line}'"));
1770 };
1771 lo = -1e19;
1772 hi = finite_bound_or_err("hi", h.parse().map_err(|e| format!("hi: {e}"))?, false)?;
1773 }
1774 2 => {
1775 // 2 lo
1776 let Some(l) = parts.next() else {
1777 return Err(format!("bound kind 2 needs 1 value: '{line}'"));
1778 };
1779 lo = finite_bound_or_err("lo", l.parse().map_err(|e| format!("lo: {e}"))?, true)?;
1780 hi = 1e19;
1781 }
1782 3 => {
1783 // 3 (free)
1784 lo = -1e19;
1785 hi = 1e19;
1786 }
1787 4 => {
1788 // 4 eq
1789 let Some(v) = parts.next() else {
1790 return Err(format!("bound kind 4 needs 1 value: '{line}'"));
1791 };
1792 // An equality has no "absent" side, so neither infinity is a
1793 // notation here and both are refused.
1794 let v: Number = finite_or_err("eq bound", v.parse().map_err(|e| format!("eq: {e}"))?)?;
1795 lo = v;
1796 hi = v;
1797 }
1798 5 => return Err("complementarity (kind 5) bounds are not supported".into()),
1799 other => return Err(format!("unknown bound kind {other}")),
1800 }
1801 Ok((lo, hi))
1802}
1803
1804fn parse_var_coef(line: &str) -> Result<(usize, Number), String> {
1805 let mut parts = line.split_whitespace();
1806 let (Some(v), Some(c)) = (parts.next(), parts.next()) else {
1807 return Err(format!("malformed var/coef line: '{line}'"));
1808 };
1809 let v: usize = v.parse().map_err(|e| format!("var idx: {e}"))?;
1810 let c: Number = finite_or_err("coefficient", c.parse().map_err(|e| format!("coef: {e}"))?)?;
1811 Ok((v, c))
1812}
1813
1814/// Build an [`NlCounts`] from `.nl` header lines 3 (`nlc nlo`) and 5
1815/// (`nlvc nlvo nlvb`).
1816///
1817/// Returns `None` unless both lines carry the full complement of
1818/// non-negative integers, so a truncated or non-conforming header reads as
1819/// "unknown" rather than as zeros — "no nonlinear variables" is a claim, and
1820/// a header that failed to parse has not made it.
1821fn parse_nl_counts(line3: &str, line5: &str) -> Option<NlCounts> {
1822 let nums = |line: &str, want: usize| -> Option<Vec<usize>> {
1823 let v: Vec<usize> = line
1824 .split_whitespace()
1825 .take(want)
1826 .map(str::parse)
1827 .collect::<Result<_, _>>()
1828 .ok()?;
1829 (v.len() == want).then_some(v)
1830 };
1831 let cons_objs = nums(line3, 2)?;
1832 let vars = nums(line5, 3)?;
1833 Some(NlCounts {
1834 nl_cons: cons_objs[0],
1835 nl_objs: cons_objs[1],
1836 nl_vars_cons: vars[0],
1837 nl_vars_objs: vars[1],
1838 nl_vars_both: vars[2],
1839 })
1840}
1841
1842struct Parser<'a> {
1843 lines: Vec<&'a str>,
1844 /// Start address and length of the source text. A recognized body
1845 /// records the byte *range* it consumed so it can be re-parsed later
1846 /// (see [`QuadBody::src`]), and every line borrows from this buffer, so
1847 /// one subtraction per line recovers its offset.
1848 ///
1849 /// Deliberately not a per-line offset table: on a 119 MB generated
1850 /// `qcqp500-3c` the file is ~17 M lines, and a `Vec<usize>` beside the
1851 /// existing `Vec<&str>` would add 136 MB to the peak this phase exists
1852 /// to reduce.
1853 txt_base: usize,
1854 txt_len: usize,
1855 pos: usize,
1856 n: usize,
1857 m: usize,
1858 num_obj: usize,
1859 /// Number of AMPL imported (external) functions declared in the header.
1860 n_funcs: usize,
1861 /// Header lines 3 and 5, when both parsed. See [`NlCounts`].
1862 nl_counts: Option<NlCounts>,
1863 /// `nzc` from header line 8: the number of Jacobian nonzeros the file
1864 /// *declares*. [`parse_nl_string`] cross-checks it against the number the
1865 /// `J` segments actually deliver, which is how a file truncated before
1866 /// them is told from a model that genuinely has none (gh#785). `None`
1867 /// when the header does not carry it in the documented shape.
1868 declared_jac_nnz: Option<usize>,
1869 ampl_options: Vec<i64>,
1870 /// Common subexpressions (`V` segments). Index in this vec is the
1871 /// CSE-local index, i.e. the global `.nl` index minus `n`.
1872 cses: Vec<Arc<Expr>>,
1873 /// Recognize degree-2 bodies from the token stream instead of building
1874 /// their trees (gh #588, Q5). Off restores the pre-Q5 parse exactly.
1875 quad_enabled: bool,
1876 /// Per-CSE answers the streaming recognizer needs about a `V` body it
1877 /// may be handed a reference to, all keyed by CSE-local index: the
1878 /// body's own degree-≤2 form, whether it is legal on a sum spine,
1879 /// whether it is legal *inside* a monomial, and its variable support.
1880 ///
1881 /// These are computed from the built `V` body with the same functions
1882 /// `NlTnlp` would apply to a whole row, so a reference costs a lookup
1883 /// and cannot drift from what the tree walk would have said. `V` bodies
1884 /// keep their trees regardless — they are shared, so the memory is
1885 /// amortized over every reference, and dropping them would mean
1886 /// rebuilding them for the rows that are *not* recognized.
1887 cse_quad: Vec<Option<Quad2>>,
1888 cse_sum_ok: Vec<bool>,
1889 cse_mono_ok: Vec<bool>,
1890 cse_vars: Vec<Vec<u32>>,
1891 cse_depth: Vec<u32>,
1892}
1893
1894/// One pending operator on the streaming recognizer's stack.
1895struct QFrame {
1896 op: QOp,
1897 /// Operands still to be read.
1898 remaining: usize,
1899 /// Read this frame's operands in monomial mode — no `+`/`-` may appear
1900 /// below it. See [`crate::nl_quadratic::is_expanded_quadratic`].
1901 mono: bool,
1902}
1903
1904/// The operators the streaming recognizer accepts. One variant per shape
1905/// that [`crate::nl_quadratic::recognize_expr`] handles; everything else
1906/// makes it bail.
1907enum QOp {
1908 Neg,
1909 Add,
1910 Sub,
1911 Mul,
1912 Div,
1913 /// `o5`/`o81`/`o83`: base and exponent both read.
1914 Pow,
1915 /// `o82`: square, exponent implicit.
1916 Square,
1917 Sum(usize),
1918}
1919
1920impl<'a> Parser<'a> {
1921 fn new(txt: &'a str, quad_enabled: bool) -> Self {
1922 let lines: Vec<&str> = txt.lines().collect();
1923 Self {
1924 lines,
1925 txt_base: txt.as_ptr() as usize,
1926 txt_len: txt.len(),
1927 pos: 0,
1928 n: 0,
1929 m: 0,
1930 num_obj: 0,
1931 n_funcs: 0,
1932 nl_counts: None,
1933 declared_jac_nnz: None,
1934 ampl_options: Vec::new(),
1935 cses: Vec::new(),
1936 quad_enabled,
1937 cse_quad: Vec::new(),
1938 cse_sum_ok: Vec::new(),
1939 cse_mono_ok: Vec::new(),
1940 cse_vars: Vec::new(),
1941 cse_depth: Vec::new(),
1942 }
1943 }
1944
1945 /// Byte offset in the source text of the start of line `line`, or the
1946 /// end of the text when the cursor has run off it.
1947 ///
1948 /// Pointer arithmetic against the same allocation, never a deref: every
1949 /// line in `lines` borrows from the source buffer, so the difference is
1950 /// its offset.
1951 fn byte_at(&self, line: usize) -> usize {
1952 match self.lines.get(line) {
1953 Some(l) => l.as_ptr() as usize - self.txt_base,
1954 None => self.txt_len,
1955 }
1956 }
1957
1958 fn next_line(&mut self) -> Option<&'a str> {
1959 while self.pos < self.lines.len() {
1960 let l = self.lines[self.pos];
1961 self.pos += 1;
1962 // Strip comment after '#' for header / data lines (but
1963 // leave the segment-tag tokens untouched — they are the
1964 // first token on the line).
1965 let trimmed = strip_comment(l).trim();
1966 if !trimmed.is_empty() {
1967 return Some(l);
1968 }
1969 }
1970 None
1971 }
1972
1973 /// Next non-blank line, comment stripped and trimmed.
1974 ///
1975 /// Borrows from the source text rather than allocating: the result
1976 /// is `&'a str`, tied to the `.nl` buffer and not to `self`, so it
1977 /// outlives the `&mut self` this took. A large `.nl` is mostly data
1978 /// lines — 620k of them for a 20k-variable model — and returning an
1979 /// owned `String` put one heap allocation on every single one.
1980 fn next_data_line(&mut self) -> Result<&'a str, String> {
1981 while self.pos < self.lines.len() {
1982 let l = self.lines[self.pos];
1983 self.pos += 1;
1984 let trimmed = strip_comment(l).trim();
1985 if !trimmed.is_empty() {
1986 return Ok(trimmed);
1987 }
1988 }
1989 Err("unexpected end of file in data line".to_string())
1990 }
1991
1992 fn parse_header(&mut self) -> Result<(), String> {
1993 let line0 = self.next_line().ok_or("empty .nl file")?;
1994 let trimmed = strip_comment(line0).trim();
1995 let first = trimmed.chars().next().ok_or("empty header line")?;
1996 if first != 'g' {
1997 return Err(format!(
1998 "only ASCII (g-) .nl files supported; got header '{trimmed}'"
1999 ));
2000 }
2001 // Line 0 is `g<count> <opt0> <opt1> ...`: the digits glued to the
2002 // `g` say how many AMPL option words follow on the same line.
2003 // A solver echoes them back in the `.sol` `Options` block, so
2004 // keep them verbatim. Malformed or truncated option lists are not
2005 // fatal — the writer falls back to a generic block.
2006 let mut words = trimmed.split_whitespace();
2007 let n_opts: usize = words.next().and_then(|w| w[1..].parse().ok()).unwrap_or(0);
2008 let opts: Vec<i64> = words.filter_map(|w| w.parse().ok()).collect();
2009 if opts.len() >= n_opts {
2010 self.ampl_options = opts[..n_opts].to_vec();
2011 }
2012
2013 // Header line 2: n_vars n_cons n_objs ranges eqns
2014 let l2 = self.next_data_line()?;
2015 let nums: Vec<&str> = l2.split_whitespace().collect();
2016 if nums.len() < 3 {
2017 return Err(format!("malformed line 2: '{l2}'"));
2018 }
2019 self.n = nums[0].parse().map_err(|e| format!("n: {e}"))?;
2020 self.m = nums[1].parse().map_err(|e| format!("m: {e}"))?;
2021 self.num_obj = nums[2].parse().map_err(|e| format!("num_obj: {e}"))?;
2022
2023 // Header line 3: `nlc nlo`. Line 4: the network-constraint census,
2024 // which pounce has no use for. Line 5: `nlvc nlvo nlvb`. Together
2025 // these are the model's nonlinearity census — see [`NlCounts`].
2026 //
2027 // A header that does not carry them in the documented shape leaves
2028 // `nl_counts` at `None` rather than at a guess: every consumer has a
2029 // walk-the-trees fallback, and a fabricated count is worse than an
2030 // absent one. The rest of the header stays tolerant in the same way
2031 // the `nfunc` read below is.
2032 let l3 = self.next_data_line()?;
2033 let _l4_network = self.next_data_line()?;
2034 let l5 = self.next_data_line()?;
2035 self.nl_counts = parse_nl_counts(l3, l5);
2036 // Line 5 (0-indexed from `g`-header): `nwv nfunc arith flags`
2037 let l6 = self.next_data_line()?;
2038 let nums5: Vec<&str> = l6.split_whitespace().collect();
2039 self.n_funcs = nums5.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
2040 // Lines 6..10 are metadata we mostly don't need. Line 7 is the
2041 // discrete-variable census; line 8 is `nzc nzo` — the Jacobian and
2042 // objective-gradient nonzero counts, kept because `nzc` is what
2043 // catches a file truncated before its `J` segments (gh#785); lines
2044 // 9 and 10 are the maximum name lengths and the common-expression
2045 // census.
2046 let _l7_discrete = self.next_data_line()?;
2047 let l8 = self.next_data_line()?;
2048 // Tolerant like the `nfunc` read above: a header that does not
2049 // carry the count in the documented shape leaves it `None`, and
2050 // the cross-check that reads it is skipped rather than fabricated.
2051 self.declared_jac_nnz = l8.split_whitespace().next().and_then(|s| s.parse().ok());
2052 let _l9_name_lens = self.next_data_line()?;
2053 let _l10_common_exprs = self.next_data_line()?;
2054 Ok(())
2055 }
2056
2057 fn peek_segment_line(&mut self) -> Option<&'a str> {
2058 let saved = self.pos;
2059 let l = self.next_line()?;
2060 self.pos = saved;
2061 Some(l)
2062 }
2063
2064 /// Eat the next non-blank line as a segment header. Returns the
2065 /// whole header (after stripping comments) and the comment text.
2066 fn eat_segment_header(&mut self) -> Result<(&'a str, &'a str), String> {
2067 let raw = self
2068 .next_line()
2069 .ok_or_else(|| "expected segment header".to_string())?;
2070 let (hdr, comment) = split_comment(raw);
2071 Ok((hdr.trim(), comment.trim()))
2072 }
2073
2074 /// Parse one `C`/`O` body, recognizing it from the token stream when
2075 /// that is possible instead of building its tree (gh #588, Q5).
2076 ///
2077 /// The cursor is the whole mechanism. `Parser` is line-based, so a
2078 /// failed recognition costs one assignment to rewind — the same trick
2079 /// `parse_funcall_arg` already uses to route a Hollerith literal — and
2080 /// the fallback then produces exactly the tree this file has always
2081 /// produced. Nothing downstream can tell which arm ran except by
2082 /// measuring memory.
2083 ///
2084 /// Only **degree-2** forms are kept. A body that recognizes as constant
2085 /// or linear is re-parsed as a tree and stored as one: the constant-row
2086 /// fold (gh #492), the linearity contract and the classifier's LP fast
2087 /// path all key on the identity zero and on trees, they are cheap
2088 /// already, and the memory that motivates this phase is entirely in
2089 /// degree-2 rows. Refusing to touch them keeps the change confined to
2090 /// the rows it is for.
2091 fn parse_body(&mut self) -> Result<NlBody, String> {
2092 if !self.quad_enabled {
2093 return Ok(NlBody::Tree(self.parse_expr()?));
2094 }
2095 let saved = self.pos;
2096 if let Some((form, vars, depth)) = self.parse_expr_quadratic() {
2097 if !form.quadratic().is_empty() {
2098 let src = self.byte_at(saved)..self.byte_at(self.pos);
2099 return Ok(NlBody::Quad(Box::new(QuadBody {
2100 form,
2101 vars,
2102 src,
2103 depth,
2104 })));
2105 }
2106 }
2107 self.pos = saved;
2108 Ok(NlBody::Tree(self.parse_expr()?))
2109 }
2110
2111 /// Read one expression off the token stream as a degree-≤2 form,
2112 /// **without building it**, or give up.
2113 ///
2114 /// This is the same computation as
2115 /// `is_expanded_quadratic(e) && recognize_expr(e)` run on the tree these
2116 /// tokens parse to — the accuracy gate and the algebra, interleaved so
2117 /// neither needs the tree. That equivalence is the phase's correctness
2118 /// claim and is asserted directly, bit for bit, over every body of every
2119 /// `.nl` file in the repository by
2120 /// `pounce-cli/tests/quad_parse_differential.rs`.
2121 ///
2122 /// Two things it must reproduce and not merely approximate:
2123 ///
2124 /// * **The exactness rule.** `is_expanded_quadratic` admits a body only
2125 /// when reading it as `½xᵀHx + aᵀx + c` repeats the additions the
2126 /// writer already wrote — expanding a *factored* form cancels, which
2127 /// is what took `airport.nl` from 16 to 300 iterations in Q4. Here
2128 /// that rule is structural rather than a separate pass: `+`/`-` are
2129 /// the spine, everything under a `*`, `/` or `^` is read in monomial
2130 /// mode, and a `+` seen in monomial mode gives up.
2131 /// * **The association.** `recognize_expr` folds an `o54` sumlist from
2132 /// the *first* operand to the last, which is also the order the AD
2133 /// tape sums in, so this folds the same way — operands arrive here in
2134 /// file order and are folded as they arrive. Summation order is not
2135 /// observable on distinct monomials and is exactly observable on
2136 /// repeated ones.
2137 ///
2138 /// On `None` the cursor is left wherever it stopped; the caller rewinds.
2139 fn parse_expr_quadratic(&mut self) -> Option<(Quad2, Vec<u32>, u32)> {
2140 let mut frames: Vec<QFrame> = Vec::new();
2141 let mut vals: Vec<Quad2> = Vec::new();
2142 let mut vars: Vec<u32> = Vec::new();
2143 // Deepest node of the tree these tokens describe. A leaf sits under
2144 // `frames.len()` operators, and a CSE reference carries its body's
2145 // depth under it — the same convention `pounce-py`'s `expr_depth`
2146 // uses, because that is who reads the answer.
2147 let mut depth: u32 = 0;
2148 let note_leaf = |frames: &[QFrame], depth: &mut u32, below: u32| {
2149 let d = u32::try_from(frames.len()).unwrap_or(u32::MAX);
2150 *depth = (*depth).max(d.saturating_add(1).saturating_add(below));
2151 };
2152
2153 loop {
2154 let mono = frames.last().is_some_and(|f| f.mono);
2155 // A `^` base must be atomic — `Const`, `Var` or a `Neg` — or the
2156 // body is a factored form dressed as a monomial. `is_monomial`
2157 // applies the same test to `Pow`'s left operand.
2158 let pow_base = matches!(
2159 frames.last(),
2160 Some(QFrame {
2161 op: QOp::Pow,
2162 remaining: 2,
2163 ..
2164 }) | Some(QFrame {
2165 op: QOp::Square,
2166 remaining: 1,
2167 ..
2168 })
2169 );
2170
2171 let raw = self.next_line()?;
2172 let tok = strip_comment(raw).trim();
2173 let first = tok.chars().next()?;
2174 match first {
2175 'n' => {
2176 // A constant base is atomic; `is_monomial` accepts it.
2177 // A non-finite literal bails out of the fast path so the
2178 // general parser reaches it and reports the error rather
2179 // than folding it into a quadratic (gh #847).
2180 let v: Number = tok[1..]
2181 .trim()
2182 .parse()
2183 .ok()
2184 .filter(|v: &Number| v.is_finite())?;
2185 note_leaf(&frames, &mut depth, 0);
2186 vals.push(Quad2::of_constant(v));
2187 }
2188 'v' => {
2189 let i: usize = tok[1..].trim().parse().ok()?;
2190 if i < self.n {
2191 note_leaf(&frames, &mut depth, 0);
2192 vars.push(u32::try_from(i).ok()?);
2193 vals.push(Quad2::of_var(i));
2194 } else {
2195 // A CSE reference lowers to `Expr::Cse`, which is
2196 // *not* one of the shapes `is_monomial` accepts as a
2197 // power's base.
2198 if pow_base {
2199 return None;
2200 }
2201 let local = i.checked_sub(self.n)?;
2202 let ok = if mono {
2203 *self.cse_mono_ok.get(local)?
2204 } else {
2205 *self.cse_sum_ok.get(local)?
2206 };
2207 if !ok {
2208 return None;
2209 }
2210 let form = self.cse_quad.get(local)?.clone()?;
2211 note_leaf(&frames, &mut depth, *self.cse_depth.get(local)?);
2212 vars.extend_from_slice(self.cse_vars.get(local)?);
2213 vals.push(form);
2214 }
2215 }
2216 'o' => {
2217 let code: i32 = tok[1..].trim().parse().ok()?;
2218 if pow_base && code != 16 {
2219 return None;
2220 }
2221 let (op, arity, child_mono) = match code {
2222 // The sum spine. Inside a monomial there is no such
2223 // thing, and the body is a factored form.
2224 0 if !mono => (QOp::Add, 2, false),
2225 1 if !mono => (QOp::Sub, 2, false),
2226 16 => (QOp::Neg, 1, mono),
2227 54 if !mono => {
2228 let count_line = self.next_data_line().ok()?;
2229 let count: usize =
2230 count_line.split_whitespace().next()?.parse().ok()?;
2231 (QOp::Sum(count), count, false)
2232 }
2233 // Monomial operators: everything below them is read
2234 // in monomial mode.
2235 2 => (QOp::Mul, 2, true),
2236 3 => (QOp::Div, 2, true),
2237 5 | 81 | 83 => (QOp::Pow, 2, true),
2238 82 => (QOp::Square, 1, true),
2239 // Transcendentals, comparisons, conditionals,
2240 // min/max lists, and `+`/`-` under a monomial.
2241 _ => return None,
2242 };
2243 if arity == 0 {
2244 // An empty `o54` is the empty sum: zero.
2245 note_leaf(&frames, &mut depth, 0);
2246 vals.push(Quad2::default());
2247 } else {
2248 frames.push(QFrame {
2249 op,
2250 remaining: arity,
2251 mono: child_mono,
2252 });
2253 continue;
2254 }
2255 }
2256 // `f` (imported function call), `h`, and anything else.
2257 _ => return None,
2258 }
2259
2260 // A value has just been produced. Close every frame it completes.
2261 while let Some(f) = frames.last_mut() {
2262 f.remaining -= 1;
2263 if f.remaining > 0 {
2264 break;
2265 }
2266 let f = frames.pop()?;
2267 let combined = apply_quad_op(f.op, &mut vals)?;
2268 vals.push(combined);
2269 }
2270 if frames.is_empty() {
2271 break;
2272 }
2273 }
2274
2275 if vals.len() != 1 {
2276 return None;
2277 }
2278 vars.sort_unstable();
2279 vars.dedup();
2280 Some((vals.pop()?, vars, depth))
2281 }
2282
2283 fn parse_expr(&mut self) -> Result<Expr, String> {
2284 let raw = self
2285 .next_line()
2286 .ok_or_else(|| "expected expression token".to_string())?;
2287 // Borrowed, not owned: this runs once per node of every
2288 // expression tree in the file, so an owned `String` here is one
2289 // heap allocation per tape op in the whole model.
2290 let tok = strip_comment(raw).trim();
2291 if tok.is_empty() {
2292 return Err("empty expression token".into());
2293 }
2294 let first = tok.chars().next().ok_or("empty expression token")?;
2295 match first {
2296 'n' => {
2297 let v: Number = tok[1..]
2298 .trim()
2299 .parse()
2300 .map_err(|e| format!("n value: {e}"))?;
2301 // A `nan` literal in the objective body reached the answer
2302 // itself: `Objective: nan` under `Solve_Succeeded` and exit
2303 // code 0 (gh #847).
2304 Ok(Expr::Const(finite_or_err("numeric literal", v)?))
2305 }
2306 'v' => {
2307 let i: usize = tok[1..]
2308 .trim()
2309 .parse()
2310 .map_err(|e| format!("v index: {e}"))?;
2311 Ok(self.var_or_cse(i)?)
2312 }
2313 'o' => {
2314 let code: i32 = tok[1..]
2315 .trim()
2316 .parse()
2317 .map_err(|e| format!("opcode: {e}"))?;
2318 self.parse_opcode(code)
2319 }
2320 'f' => {
2321 // AMPL imported (external) function call: `f<id> <nargs>`
2322 // followed by nargs child expressions (or string literals).
2323 let rest = &tok[1..];
2324 let mut parts = rest.split_whitespace();
2325 let id_str = parts
2326 .next()
2327 .ok_or_else(|| format!("missing function id in '{tok}'"))?;
2328 let nargs_str = parts
2329 .next()
2330 .ok_or_else(|| format!("missing nargs in '{tok}'"))?;
2331 let id: usize = id_str
2332 .parse()
2333 .map_err(|e| format!("bad function id '{id_str}': {e}"))?;
2334 let nargs: usize = nargs_str
2335 .parse()
2336 .map_err(|e| format!("bad funcall nargs '{nargs_str}': {e}"))?;
2337 let mut args: Vec<FuncallArg> = Vec::with_capacity(nargs);
2338 for _ in 0..nargs {
2339 args.push(self.parse_funcall_arg()?);
2340 }
2341 Ok(Expr::Funcall { id, args })
2342 }
2343 't' | 'u' => Err(format!("unsupported expression token '{tok}'")),
2344 other => Err(format!(
2345 "unexpected expression token start '{other}': '{tok}'"
2346 )),
2347 }
2348 }
2349
2350 /// Parse one argument to an AMPL imported function. An argument
2351 /// is either a normal expression (real-valued) or a string literal
2352 /// in the form `h<len>:<chars>`. AMPL emits string args only when the
2353 /// function was declared `FUNCADD_STRING_ARGS` (e.g. component name
2354 /// or a parameters-directory path for IDAES Helmholtz functions).
2355 fn parse_funcall_arg(&mut self) -> Result<FuncallArg, String> {
2356 // Peek the next non-blank line so we can route `h...` differently.
2357 let saved = self.pos;
2358 let raw = self
2359 .next_line()
2360 .ok_or_else(|| "expected funcall argument".to_string())?;
2361 // A string arg is a Hollerith literal `h<len>:<chars>` where the
2362 // chars are *exactly* `<len>` bytes and may legitimately contain
2363 // '#'. We must NOT strip a trailing comment before extracting the
2364 // content (that would truncate e.g. a path like `a#b`), and we
2365 // honor the declared length rather than splitting loosely on ':'.
2366 // Detect the form from the leading non-blank char of the raw line;
2367 // no expression opcode (`o`/`v`/`n`/`f`) begins with 'h'.
2368 let lead = raw.trim_start();
2369 if let Some(after_h) = lead.strip_prefix('h') {
2370 let colon = after_h
2371 .find(':')
2372 .ok_or_else(|| format!("malformed Hollerith string arg (no ':'): {lead:?}"))?;
2373 let len: usize = after_h[..colon]
2374 .trim()
2375 .parse()
2376 .map_err(|e| format!("Hollerith length in {lead:?}: {e}"))?;
2377 let chars = &after_h[colon + 1..];
2378 if chars.len() < len {
2379 return Err(format!(
2380 "Hollerith string shorter than declared length {len}: {chars:?}"
2381 ));
2382 }
2383 // Take exactly `len` bytes; anything past it (trailing
2384 // whitespace, a real comment) is not part of the string.
2385 if !chars.is_char_boundary(len) {
2386 return Err(format!(
2387 "Hollerith length {len} splits a multibyte char in {chars:?}"
2388 ));
2389 }
2390 Ok(FuncallArg::Str(chars[..len].to_string()))
2391 } else {
2392 // Rewind: parse_expr re-consumes the line we just peeked.
2393 self.pos = saved;
2394 Ok(FuncallArg::Real(self.parse_expr()?))
2395 }
2396 }
2397
2398 fn parse_opcode(&mut self, code: i32) -> Result<Expr, String> {
2399 match code {
2400 0 => {
2401 let a = self.parse_expr()?;
2402 let b = self.parse_expr()?;
2403 Ok(Expr::Binary(BinOp::Add, Box::new(a), Box::new(b)))
2404 }
2405 1 => {
2406 let a = self.parse_expr()?;
2407 let b = self.parse_expr()?;
2408 Ok(Expr::Binary(BinOp::Sub, Box::new(a), Box::new(b)))
2409 }
2410 2 => {
2411 let a = self.parse_expr()?;
2412 let b = self.parse_expr()?;
2413 Ok(Expr::Binary(BinOp::Mul, Box::new(a), Box::new(b)))
2414 }
2415 3 => {
2416 let a = self.parse_expr()?;
2417 let b = self.parse_expr()?;
2418 Ok(Expr::Binary(BinOp::Div, Box::new(a), Box::new(b)))
2419 }
2420 5 => {
2421 let a = self.parse_expr()?;
2422 let b = self.parse_expr()?;
2423 Ok(Expr::Binary(BinOp::Pow, Box::new(a), Box::new(b)))
2424 }
2425 15 => Ok(Expr::Unary(UnaryOp::Abs, Box::new(self.parse_expr()?))),
2426 16 => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.parse_expr()?))),
2427 39 => Ok(Expr::Unary(UnaryOp::Sqrt, Box::new(self.parse_expr()?))),
2428 41 => Ok(Expr::Unary(UnaryOp::Sin, Box::new(self.parse_expr()?))),
2429 42 => Ok(Expr::Unary(UnaryOp::Log10, Box::new(self.parse_expr()?))),
2430 43 => Ok(Expr::Unary(UnaryOp::Log, Box::new(self.parse_expr()?))),
2431 44 => Ok(Expr::Unary(UnaryOp::Exp, Box::new(self.parse_expr()?))),
2432 46 => Ok(Expr::Unary(UnaryOp::Cos, Box::new(self.parse_expr()?))),
2433 38 => Ok(Expr::Unary(UnaryOp::Tan, Box::new(self.parse_expr()?))),
2434 49 => Ok(Expr::Unary(UnaryOp::Atan, Box::new(self.parse_expr()?))),
2435 53 => Ok(Expr::Unary(UnaryOp::Acos, Box::new(self.parse_expr()?))),
2436 40 => Ok(Expr::Unary(UnaryOp::Sinh, Box::new(self.parse_expr()?))),
2437 45 => Ok(Expr::Unary(UnaryOp::Cosh, Box::new(self.parse_expr()?))),
2438 37 => Ok(Expr::Unary(UnaryOp::Tanh, Box::new(self.parse_expr()?))),
2439 51 => Ok(Expr::Unary(UnaryOp::Asin, Box::new(self.parse_expr()?))),
2440 52 => Ok(Expr::Unary(UnaryOp::Acosh, Box::new(self.parse_expr()?))),
2441 50 => Ok(Expr::Unary(UnaryOp::Asinh, Box::new(self.parse_expr()?))),
2442 47 => Ok(Expr::Unary(UnaryOp::Atanh, Box::new(self.parse_expr()?))),
2443 // atan2(y, x): binary, operand order `y` then `x`.
2444 48 => {
2445 let a = self.parse_expr()?;
2446 let b = self.parse_expr()?;
2447 Ok(Expr::Binary(BinOp::Atan2, Box::new(a), Box::new(b)))
2448 }
2449 // Relational comparisons (binary). Operand order is
2450 // `left OP right`.
2451 22 => self.parse_compare(CmpOp::Lt),
2452 23 => self.parse_compare(CmpOp::Le),
2453 24 => self.parse_compare(CmpOp::Eq),
2454 28 => self.parse_compare(CmpOp::Ge),
2455 29 => self.parse_compare(CmpOp::Gt),
2456 30 => self.parse_compare(CmpOp::Ne),
2457 // Logical connectives.
2458 20 => {
2459 let a = self.parse_expr()?;
2460 let b = self.parse_expr()?;
2461 Ok(Expr::Or(Box::new(a), Box::new(b)))
2462 }
2463 21 => {
2464 let a = self.parse_expr()?;
2465 let b = self.parse_expr()?;
2466 Ok(Expr::And(Box::new(a), Box::new(b)))
2467 }
2468 34 => Ok(Expr::Not(Box::new(self.parse_expr()?))),
2469 // if-then-else: condition, then-value, else-value.
2470 35 => {
2471 let cond = self.parse_expr()?;
2472 let then_ = self.parse_expr()?;
2473 let else_ = self.parse_expr()?;
2474 Ok(Expr::Cond {
2475 cond: Box::new(cond),
2476 then_: Box::new(then_),
2477 else_: Box::new(else_),
2478 })
2479 }
2480 54 => {
2481 // Variadic sum: next data line gives the count.
2482 let count_line = self.next_data_line()?;
2483 let count: usize = count_line
2484 .split_whitespace()
2485 .next()
2486 .ok_or_else(|| "missing variadic count".to_string())?
2487 .parse()
2488 .map_err(|e| format!("variadic count: {e}"))?;
2489 let mut args = Vec::with_capacity(count);
2490 for _ in 0..count {
2491 args.push(self.parse_expr()?);
2492 }
2493 Ok(Expr::Sum(args))
2494 }
2495 // Variadic min (o11 MINLIST) / max (o12 MAXLIST): like o54,
2496 // a count data line followed by that many operands.
2497 11 | 12 => {
2498 let count_line = self.next_data_line()?;
2499 let count: usize = count_line
2500 .split_whitespace()
2501 .next()
2502 .ok_or_else(|| "missing min/max list count".to_string())?
2503 .parse()
2504 .map_err(|e| format!("min/max list count: {e}"))?;
2505 let mut args = Vec::with_capacity(count);
2506 for _ in 0..count {
2507 args.push(self.parse_expr()?);
2508 }
2509 if code == 11 {
2510 Ok(Expr::MinList(args))
2511 } else {
2512 Ok(Expr::MaxList(args))
2513 }
2514 }
2515 // AMPL power specializations (ASL `opcode.hd` 81/82/83). AMPL
2516 // emits these in place of the general `o5` (OPPOW) as a hint that
2517 // one operand is constant. The distinction exists because an
2518 // integer / half-integer constant power is evaluated by a
2519 // mul/sqrt chain that stays real for a negative base, whereas the
2520 // general `pow` (via `exp(c·ln x)`) returns NaN there. Structurally
2521 // they read exactly like `o5`, so they lower to the same `Pow` AST
2522 // and reuse the existing constant-power tape lowering (see
2523 // `nl_tape::try_emit_const_pow`). Arity/operand order confirmed
2524 // against the ASL reader and the `ampl/mp` opcode table:
2525 // POW_CONST_EXP / POW_CONST_BASE are binary `base, exp`; POW2 is
2526 // unary with an implicit exponent of 2.
2527 //
2528 // o81 OP1POW: `base ^ (const exponent)` — binary, operands
2529 // `base` then `exp` (the exponent is a numeric node here).
2530 81 => {
2531 let base = self.parse_expr()?;
2532 let exp = self.parse_expr()?;
2533 Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2534 }
2535 // o82 OP2POW: square — unary, single operand; exponent 2 implicit.
2536 82 => {
2537 let base = self.parse_expr()?;
2538 Ok(Expr::Binary(
2539 BinOp::Pow,
2540 Box::new(base),
2541 Box::new(Expr::Const(2.0)),
2542 ))
2543 }
2544 // o83 OPCPOW: `(const base) ^ exponent` — binary, operands `base`
2545 // (the numeric node) then `exp`.
2546 83 => {
2547 let base = self.parse_expr()?;
2548 let exp = self.parse_expr()?;
2549 Ok(Expr::Binary(BinOp::Pow, Box::new(base), Box::new(exp)))
2550 }
2551 other => Err(format!("unsupported opcode o{other}")),
2552 }
2553 }
2554
2555 /// Parse the two operands of a relational opcode into an
2556 /// [`Expr::Compare`]. Operand order is `left OP right`.
2557 fn parse_compare(&mut self, op: CmpOp) -> Result<Expr, String> {
2558 let a = self.parse_expr()?;
2559 let b = self.parse_expr()?;
2560 Ok(Expr::Compare(op, Box::new(a), Box::new(b)))
2561 }
2562
2563 /// Resolve a `v<i>` token into either a plain variable reference
2564 /// (`i < n`) or a shared CSE reference (`i >= n`).
2565 fn var_or_cse(&self, i: usize) -> Result<Expr, String> {
2566 if i < self.n {
2567 Ok(Expr::Var(i))
2568 } else {
2569 let local = i - self.n;
2570 self.cses
2571 .get(local)
2572 .map(|rc| Expr::Cse(rc.clone()))
2573 .ok_or_else(|| {
2574 format!(
2575 "v{i} references CSE {local} but only {} have been defined",
2576 self.cses.len()
2577 )
2578 })
2579 }
2580 }
2581
2582 /// Parse a `V<k> <nlin> <type>` common-subexpression segment. The
2583 /// CSE evaluates to `nonlinear_expr + sum_i coef_i * v_{var_i}`.
2584 /// CSEs are numbered starting at `n` and must appear in order.
2585 fn parse_v_segment(&mut self) -> Result<(), String> {
2586 let (hdr, _) = self.eat_segment_header()?;
2587 let parts: Vec<&str> = hdr.split_whitespace().collect();
2588 if parts.len() < 2 {
2589 return Err(format!("malformed V-segment header: {hdr}"));
2590 }
2591 let cse_idx = parse_segment_index(parts[0], 'V')?;
2592 let nlin: usize = parts[1].parse().map_err(|e| format!("V nlin: {e}"))?;
2593 // parts[2] (type) is ignored; values >0 just mark special-purpose CSEs.
2594 let mut linear: Vec<(usize, Number)> = Vec::with_capacity(nlin);
2595 for _ in 0..nlin {
2596 let line = self.next_data_line()?;
2597 let (var, coef) = parse_var_coef(line)?;
2598 linear.push((var, coef));
2599 }
2600 let nonlin = self.parse_expr()?;
2601 // Build `nonlin + sum coef_i * v_{var_i}`. Linear terms can
2602 // reference earlier CSEs as well as plain variables.
2603 let mut combined = nonlin;
2604 for (var, coef) in linear {
2605 let v_expr = self.var_or_cse(var)?;
2606 let term = if coef == 1.0 {
2607 v_expr
2608 } else {
2609 Expr::Binary(BinOp::Mul, Box::new(Expr::Const(coef)), Box::new(v_expr))
2610 };
2611 combined = Expr::Binary(BinOp::Add, Box::new(combined), Box::new(term));
2612 }
2613 if cse_idx < self.n {
2614 return Err(format!("V{cse_idx} below n={}", self.n));
2615 }
2616 let local = cse_idx - self.n;
2617 if local != self.cses.len() {
2618 return Err(format!(
2619 "V-segment index V{cse_idx} out of order; expected V{}",
2620 self.n + self.cses.len()
2621 ));
2622 }
2623 // What a reference to this body would mean to the streaming
2624 // recognizer, answered once here rather than per reference. The
2625 // `V` tree exists at this point, so these are the *same* functions
2626 // `NlTnlp` applies to a whole row — the parse-time recognizer never
2627 // gets a second opinion about a CSE.
2628 if self.quad_enabled {
2629 self.cse_quad
2630 .push(crate::nl_quadratic::recognize_expr(&combined));
2631 self.cse_sum_ok
2632 .push(crate::nl_quadratic::is_expanded_quadratic(&combined));
2633 self.cse_mono_ok
2634 .push(crate::nl_quadratic::is_monomial_expr(&combined));
2635 let mut vars: BTreeSet<usize> = BTreeSet::new();
2636 collect_vars(&combined, &mut vars);
2637 self.cse_vars
2638 .push(vars.into_iter().map(|v| v as u32).collect());
2639 self.cse_depth.push(expr_tree_depth(&combined));
2640 }
2641 self.cses.push(Arc::new(combined));
2642 Ok(())
2643 }
2644}
2645
2646/// Combine the operands of one recognized operator, mirroring
2647/// [`crate::nl_quadratic::recognize_expr`]'s `Apply` arm term for term.
2648///
2649/// Every difference from that function is a difference in the coefficients
2650/// this parser stores, so there is nothing here that is "equivalent but
2651/// tidier": the division scales by the reciprocal because that is what the
2652/// tree walk does, and the sumlist folds back to front for the same reason.
2653/// Nesting depth of a `V`-segment body, leaf = 1, a `Cse` reference
2654/// counting one level above its body — the convention `pounce-py`'s
2655/// `expr_depth` uses, since that is the guard the answer feeds.
2656///
2657/// Recursive, and safe to be: this only ever runs on a tree
2658/// [`Parser::parse_expr`] has just built *recursively* on this same stack,
2659/// so a frame that fits the parser fits this.
2660fn expr_tree_depth(e: &Expr) -> u32 {
2661 let deepest = |kids: &mut dyn Iterator<Item = &Expr>| {
2662 kids.fold(0u32, |acc, k| acc.max(expr_tree_depth(k)))
2663 };
2664 1 + match e {
2665 Expr::Const(_) | Expr::Var(_) => 0,
2666 Expr::Binary(_, a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
2667 deepest(&mut [&**a, &**b].into_iter())
2668 }
2669 Expr::Unary(_, a) | Expr::Not(a) => expr_tree_depth(a),
2670 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => deepest(&mut args.iter()),
2671 Expr::Cond { cond, then_, else_ } => {
2672 deepest(&mut [&**cond, &**then_, &**else_].into_iter())
2673 }
2674 Expr::Funcall { args, .. } => deepest(&mut args.iter().filter_map(|a| match a {
2675 FuncallArg::Real(inner) => Some(inner),
2676 FuncallArg::Str(_) => None,
2677 })),
2678 Expr::Cse(body) => expr_tree_depth(body),
2679 }
2680}
2681
2682fn apply_quad_op(op: QOp, vals: &mut Vec<Quad2>) -> Option<Quad2> {
2683 let pop2 = |vals: &mut Vec<Quad2>| -> Option<(Quad2, Quad2)> {
2684 let b = vals.pop()?;
2685 let a = vals.pop()?;
2686 Some((a, b))
2687 };
2688 Some(match op {
2689 QOp::Sum(n) => {
2690 let at = vals.len().checked_sub(n)?;
2691 let mut acc = Quad2::default();
2692 // Operands arrive in file order, so `drain` already yields them
2693 // front to back — the order `recognize_expr` folds in, and the
2694 // order the AD tape sums in. Do not reverse this: floating-point
2695 // addition is not associative, and on repeated monomials the two
2696 // orders do not agree bit for bit.
2697 for p in vals.drain(at..) {
2698 acc = Quad2::add(acc, p);
2699 }
2700 acc
2701 }
2702 QOp::Neg => vals.pop()?.neg(),
2703 QOp::Add => {
2704 let (a, b) = pop2(vals)?;
2705 Quad2::add(a, b)
2706 }
2707 QOp::Sub => {
2708 let (a, b) = pop2(vals)?;
2709 Quad2::add(a, b.neg())
2710 }
2711 QOp::Mul => {
2712 let (a, b) = pop2(vals)?;
2713 a.mul(&b)?
2714 }
2715 QOp::Div => {
2716 let (a, b) = pop2(vals)?;
2717 let d = b.as_constant()?;
2718 if d == 0.0 {
2719 return None;
2720 }
2721 let mut out = a.div_by_constant(d);
2722 out.absorb_flags(&b);
2723 out
2724 }
2725 QOp::Pow => {
2726 let (a, b) = pop2(vals)?;
2727 let exp = b.as_constant()?;
2728 let mut out = if exp == 0.0 {
2729 Quad2::of_constant(1.0)
2730 } else if exp == 1.0 {
2731 a
2732 } else if exp == 2.0 {
2733 a.mul(&a)?
2734 } else {
2735 return None;
2736 };
2737 // The exponent is read out of a form with `as_constant`, which
2738 // leaves its flags behind; `Div` above does the same.
2739 out.absorb_flags(&b);
2740 out
2741 }
2742 // `o82` is `Pow(base, Const(2.0))` with the exponent left implicit,
2743 // so it takes the `exp == 2.0` branch above.
2744 QOp::Square => {
2745 let a = vals.pop()?;
2746 a.mul(&a)?
2747 }
2748 })
2749}
2750
2751/// Re-parse one recognized body from the bytes it was recognized from.
2752///
2753/// The point is that this is the *same* function the original parse ran, on
2754/// the *same* bytes, with the same `n` and the same `V`-segment bodies — so
2755/// the tree it returns is the tree that parse would have built, down to the
2756/// `Arc` identities that `HybridTape::build_multi` keys CSE sharing on.
2757/// Deriving an equivalent tree from the stored coefficients instead would
2758/// be a different tree, evaluated by a different tape, and this phase would
2759/// stop being invisible from outside.
2760fn parse_body_fragment(txt: &str, n: usize, cses: &[Arc<Expr>]) -> Result<Expr, String> {
2761 let mut p = Parser::new(txt, false);
2762 p.n = n;
2763 p.cses = cses.to_vec();
2764 p.parse_expr()
2765}
2766
2767fn strip_comment(s: &str) -> &str {
2768 match s.find('#') {
2769 Some(i) => &s[..i],
2770 None => s,
2771 }
2772}
2773
2774fn split_comment(s: &str) -> (&str, &str) {
2775 match s.find('#') {
2776 Some(i) => (&s[..i], &s[i + 1..]),
2777 None => (s, ""),
2778 }
2779}
2780
2781// --------------------------------------------------------------------
2782// Expression evaluation and gradient (tree walkers, kept for tests).
2783// The hot paths in `NlTnlp` use the flat `Tape` AD in `nl_tape.rs`
2784// instead — see `Tape::gradient_seed` / `Tape::hessian_accumulate`.
2785// --------------------------------------------------------------------
2786
2787/// Forward-mode value evaluation.
2788pub fn eval_expr(e: &Expr, x: &[Number]) -> Number {
2789 match e {
2790 Expr::Const(c) => *c,
2791 Expr::Var(i) => x[*i],
2792 Expr::Binary(op, a, b) => {
2793 let va = eval_expr(a, x);
2794 let vb = eval_expr(b, x);
2795 match op {
2796 BinOp::Add => va + vb,
2797 BinOp::Sub => va - vb,
2798 BinOp::Mul => va * vb,
2799 BinOp::Div => va / vb,
2800 BinOp::Pow => va.powf(vb),
2801 BinOp::Atan2 => va.atan2(vb),
2802 BinOp::CEntropy => crate::nl_tape::centropy(va, vb),
2803 }
2804 }
2805 Expr::Unary(op, a) => {
2806 let va = eval_expr(a, x);
2807 match op {
2808 UnaryOp::Neg => -va,
2809 UnaryOp::Sqrt => va.sqrt(),
2810 UnaryOp::Log => va.ln(),
2811 UnaryOp::Log10 => va.log10(),
2812 UnaryOp::Exp => va.exp(),
2813 UnaryOp::Abs => va.abs(),
2814 UnaryOp::Sin => va.sin(),
2815 UnaryOp::Cos => va.cos(),
2816 UnaryOp::Tan => va.tan(),
2817 UnaryOp::Atan => va.atan(),
2818 UnaryOp::Acos => va.acos(),
2819 UnaryOp::Sinh => va.sinh(),
2820 UnaryOp::Cosh => va.cosh(),
2821 UnaryOp::Tanh => va.tanh(),
2822 UnaryOp::Asin => va.asin(),
2823 UnaryOp::Acosh => va.acosh(),
2824 UnaryOp::Asinh => va.asinh(),
2825 UnaryOp::Atanh => va.atanh(),
2826 UnaryOp::Erf => crate::nl_tape::erf(va),
2827 UnaryOp::XLogX => crate::nl_tape::xlogx(va),
2828 }
2829 }
2830 Expr::Sum(args) => args.iter().map(|a| eval_expr(a, x)).sum(),
2831 Expr::MinList(args) => args
2832 .iter()
2833 .map(|a| eval_expr(a, x))
2834 .fold(Number::INFINITY, Number::min),
2835 Expr::MaxList(args) => args
2836 .iter()
2837 .map(|a| eval_expr(a, x))
2838 .fold(Number::NEG_INFINITY, Number::max),
2839 Expr::Compare(op, a, b) => {
2840 let va = eval_expr(a, x);
2841 let vb = eval_expr(b, x);
2842 let truth = match op {
2843 CmpOp::Lt => va < vb,
2844 CmpOp::Le => va <= vb,
2845 CmpOp::Eq => va == vb,
2846 CmpOp::Ge => va >= vb,
2847 CmpOp::Gt => va > vb,
2848 CmpOp::Ne => va != vb,
2849 };
2850 if truth { 1.0 } else { 0.0 }
2851 }
2852 Expr::And(a, b) => {
2853 if eval_expr(a, x) != 0.0 && eval_expr(b, x) != 0.0 {
2854 1.0
2855 } else {
2856 0.0
2857 }
2858 }
2859 Expr::Or(a, b) => {
2860 if eval_expr(a, x) != 0.0 || eval_expr(b, x) != 0.0 {
2861 1.0
2862 } else {
2863 0.0
2864 }
2865 }
2866 Expr::Not(a) => {
2867 if eval_expr(a, x) == 0.0 {
2868 1.0
2869 } else {
2870 0.0
2871 }
2872 }
2873 Expr::Cond { cond, then_, else_ } => {
2874 if eval_expr(cond, x) != 0.0 {
2875 eval_expr(then_, x)
2876 } else {
2877 eval_expr(else_, x)
2878 }
2879 }
2880 Expr::Cse(body) => eval_expr(body, x),
2881 Expr::Funcall { .. } => panic!(
2882 "eval_expr: AMPL imported function called without an external resolver; \
2883 evaluate through the tape AD path (Tape::build_with_externals) instead"
2884 ),
2885 }
2886}
2887
2888/// Index of the active operand of an n-ary min (`want_min = true`) or
2889/// max (`want_min = false`) list at point `x`: the smallest / largest
2890/// value, with ties resolved to the first such operand (the
2891/// conventional subgradient choice). Returns `None` for an empty list.
2892fn argmin_argmax(args: &[Expr], x: &[Number], want_min: bool) -> Option<usize> {
2893 let mut best: Option<(usize, Number)> = None;
2894 for (i, a) in args.iter().enumerate() {
2895 let v = eval_expr(a, x);
2896 match best {
2897 None => best = Some((i, v)),
2898 Some((_, bv)) => {
2899 // Strict comparison keeps the FIRST extremal operand on
2900 // ties, matching the subgradient convention used by Abs
2901 // and Select elsewhere in the tape.
2902 if (want_min && v < bv) || (!want_min && v > bv) {
2903 best = Some((i, v));
2904 }
2905 }
2906 }
2907 }
2908 best.map(|(i, _)| i)
2909}
2910
2911/// Reverse-mode gradient: accumulates `seed * d(expr)/dx_i` into `grad`.
2912pub fn grad_expr(e: &Expr, x: &[Number], seed: Number, grad: &mut [Number]) {
2913 match e {
2914 Expr::Const(_) => {}
2915 Expr::Var(i) => grad[*i] += seed,
2916 Expr::Binary(op, a, b) => {
2917 let va = eval_expr(a, x);
2918 let vb = eval_expr(b, x);
2919 match op {
2920 BinOp::Add => {
2921 grad_expr(a, x, seed, grad);
2922 grad_expr(b, x, seed, grad);
2923 }
2924 BinOp::Sub => {
2925 grad_expr(a, x, seed, grad);
2926 grad_expr(b, x, -seed, grad);
2927 }
2928 BinOp::Mul => {
2929 grad_expr(a, x, seed * vb, grad);
2930 grad_expr(b, x, seed * va, grad);
2931 }
2932 BinOp::Div => {
2933 grad_expr(a, x, seed / vb, grad);
2934 grad_expr(b, x, -seed * va / (vb * vb), grad);
2935 }
2936 BinOp::Pow => {
2937 // d/da: b * a^(b-1)
2938 let dpa = vb * va.powf(vb - 1.0);
2939 grad_expr(a, x, seed * dpa, grad);
2940 // d/db: a^b * ln(a) (only valid for a>0; simple branch)
2941 if va > 0.0 {
2942 let dpb = va.powf(vb) * va.ln();
2943 grad_expr(b, x, seed * dpb, grad);
2944 }
2945 }
2946 BinOp::Atan2 => {
2947 // atan2(y=a, x=b): d/dy = x/(x²+y²), d/dx = -y/(x²+y²)
2948 let d = va * va + vb * vb;
2949 grad_expr(a, x, seed * vb / d, grad);
2950 grad_expr(b, x, -seed * va / d, grad);
2951 }
2952 BinOp::CEntropy => {
2953 grad_expr(a, x, seed * crate::nl_tape::centropy_da(va, vb), grad);
2954 grad_expr(b, x, seed * crate::nl_tape::centropy_db(va, vb), grad);
2955 }
2956 }
2957 }
2958 Expr::Unary(op, a) => {
2959 let va = eval_expr(a, x);
2960 let d = match op {
2961 UnaryOp::Neg => -1.0,
2962 UnaryOp::Sqrt => 0.5 / va.sqrt(),
2963 UnaryOp::Log => 1.0 / va,
2964 UnaryOp::Log10 => 1.0 / (va * std::f64::consts::LN_10),
2965 UnaryOp::Exp => va.exp(),
2966 UnaryOp::Abs => {
2967 if va > 0.0 {
2968 1.0
2969 } else if va < 0.0 {
2970 -1.0
2971 } else {
2972 0.0
2973 }
2974 }
2975 UnaryOp::Sin => va.cos(),
2976 UnaryOp::Cos => -va.sin(),
2977 UnaryOp::Tan => {
2978 let t = va.tan();
2979 1.0 + t * t
2980 }
2981 UnaryOp::Atan => 1.0 / (1.0 + va * va),
2982 UnaryOp::Acos => -1.0 / (1.0 - va * va).sqrt(),
2983 UnaryOp::Sinh => va.cosh(),
2984 UnaryOp::Cosh => va.sinh(),
2985 UnaryOp::Tanh => {
2986 let t = va.tanh();
2987 1.0 - t * t
2988 }
2989 UnaryOp::Asin => 1.0 / (1.0 - va * va).sqrt(),
2990 UnaryOp::Acosh => 1.0 / (va * va - 1.0).sqrt(),
2991 UnaryOp::Asinh => 1.0 / (va * va + 1.0).sqrt(),
2992 UnaryOp::Atanh => 1.0 / (1.0 - va * va),
2993 UnaryOp::Erf => crate::nl_tape::erf_d1(va),
2994 UnaryOp::XLogX => crate::nl_tape::xlogx_d1(va),
2995 };
2996 grad_expr(a, x, seed * d, grad);
2997 }
2998 Expr::Sum(args) => {
2999 for arg in args {
3000 grad_expr(arg, x, seed, grad);
3001 }
3002 }
3003 // min/max are piecewise linear: the seed flows only through the
3004 // currently-active (smallest / largest) operand — a subgradient.
3005 // Ties resolve to the first such operand. Empty list: no operand,
3006 // no derivative (matches the ±inf eval fold).
3007 Expr::MinList(args) => {
3008 if let Some(k) = argmin_argmax(args, x, true) {
3009 grad_expr(&args[k], x, seed, grad);
3010 }
3011 }
3012 Expr::MaxList(args) => {
3013 if let Some(k) = argmin_argmax(args, x, false) {
3014 grad_expr(&args[k], x, seed, grad);
3015 }
3016 }
3017 // Comparisons and logical connectives are piecewise constant:
3018 // zero derivative, so no seed propagates into their operands.
3019 Expr::Compare(_, _, _) | Expr::And(_, _) | Expr::Or(_, _) | Expr::Not(_) => {}
3020 // if-then-else: differentiate only the active branch. The
3021 // branch-switch discontinuity contributes no derivative.
3022 Expr::Cond { cond, then_, else_ } => {
3023 if eval_expr(cond, x) != 0.0 {
3024 grad_expr(then_, x, seed, grad);
3025 } else {
3026 grad_expr(else_, x, seed, grad);
3027 }
3028 }
3029 Expr::Cse(body) => grad_expr(body, x, seed, grad),
3030 Expr::Funcall { .. } => {
3031 panic!("grad_expr: AMPL imported function called without an external resolver")
3032 }
3033 }
3034}
3035
3036/// Walk `e` and insert every `Var(i)` index into `out`.
3037///
3038/// Shared `Cse` bodies are visited once per call, memoized on `Arc` pointer
3039/// identity. Without that this is Θ(2^depth) on a DAG that shares
3040/// subexpressions — each reference re-walks the whole body — and presolve
3041/// calls this on every solve (`get_variables_linearity`). Skipping a
3042/// repeat visit cannot change the answer: `out` is a set, and a second
3043/// walk of the same body inserts exactly the indices the first already did.
3044pub fn collect_vars(e: &Expr, out: &mut BTreeSet<usize>) {
3045 // `HashSet::new` does not allocate until the first insert, so an
3046 // expression with no CSEs pays nothing for the memo.
3047 let mut seen: std::collections::HashSet<*const Expr> = std::collections::HashSet::new();
3048 collect_vars_memo(e, out, &mut seen);
3049}
3050
3051fn collect_vars_memo(
3052 e: &Expr,
3053 out: &mut BTreeSet<usize>,
3054 seen: &mut std::collections::HashSet<*const Expr>,
3055) {
3056 match e {
3057 Expr::Const(_) => {}
3058 Expr::Var(i) => {
3059 out.insert(*i);
3060 }
3061 Expr::Binary(_, a, b) => {
3062 collect_vars_memo(a, out, seen);
3063 collect_vars_memo(b, out, seen);
3064 }
3065 Expr::Unary(_, a) => collect_vars_memo(a, out, seen),
3066 Expr::Sum(args) | Expr::MinList(args) | Expr::MaxList(args) => {
3067 for a in args {
3068 collect_vars_memo(a, out, seen);
3069 }
3070 }
3071 // Collect from every child, including the condition: even
3072 // though the comparison/branch-test contributes no derivative,
3073 // the variables it reads are genuinely "used" by the problem,
3074 // and being conservative here only ever adds structural zeros
3075 // to the Jacobian/Hessian (never drops a real nonzero).
3076 Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
3077 collect_vars_memo(a, out, seen);
3078 collect_vars_memo(b, out, seen);
3079 }
3080 Expr::Not(a) => collect_vars_memo(a, out, seen),
3081 Expr::Cond { cond, then_, else_ } => {
3082 collect_vars_memo(cond, out, seen);
3083 collect_vars_memo(then_, out, seen);
3084 collect_vars_memo(else_, out, seen);
3085 }
3086 Expr::Cse(body) => {
3087 if seen.insert(Arc::as_ptr(body)) {
3088 collect_vars_memo(body, out, seen);
3089 }
3090 }
3091 Expr::Funcall { args, .. } => {
3092 for a in args {
3093 if let FuncallArg::Real(e) = a {
3094 collect_vars_memo(e, out, seen);
3095 }
3096 }
3097 }
3098 }
3099}
3100
3101// --------------------------------------------------------------------
3102// TNLP wrapper — backed by `Tape` reverse-mode AD for value, gradient,
3103// Jacobian, and Hessian. Built once at construction; every solve-time
3104// callback is a tape sweep, no expression-tree recursion.
3105// --------------------------------------------------------------------
3106
3107/// Per-color decoding instruction for `eval_h` Hessian-coloring.
3108/// After a directional Hessian-vector product `compressed = H · s_c`,
3109/// the entry at row `row` came uniquely from column `col` (because
3110/// no two columns of color `c` share any nonzero row), so we
3111/// scatter `compressed[row]` into `values[hess_idx]`.
3112#[derive(Debug, Clone)]
3113struct ColorWrite {
3114 row: u32,
3115 hess_idx: u32,
3116}
3117
3118/// Constraint-block [`HybridTape`]: one local op list per summand plus a
3119/// **shared prelude** holding every CSE body referenced by two or more
3120/// summands, evaluated once per sweep.
3121///
3122/// `con_tapes` builds an independent flat `Tape` per summand, so a `.nl`
3123/// defined variable (`V` segment) referenced from many rows is re-emitted —
3124/// and re-evaluated — once per reference. That is invisible on most models
3125/// but quadratic-ish in the wrong shape: on Mittelmann's `robot_a`
3126/// (n = 1001, m = 52013, 12003 defined variables each feeding 13 rows) the
3127/// flat tapes total 3.6M ops per `eval_g` against 894k for the shared
3128/// prelude — 4.0x the arithmetic, paid ~10x per iteration inside the line
3129/// search. See pounce#476.
3130///
3131/// `eval_g` reads this unconditionally; `eval_jac_g` and `eval_h` read it
3132/// above their respective op-ratio gates ([`HYBRID_JAC_MIN_OP_RATIO`],
3133/// [`HYBRID_HESS_MIN_OP_RATIO`]) — the hybrid traversal carries per-op
3134/// overhead the flat tapes do not, so each derivative order has to earn
3135/// its switch.
3136#[derive(Debug, Clone)]
3137struct ConHybrid {
3138 tape: HybridTape,
3139 /// `row_start[i]..row_start[i + 1]` are the summands of constraint `i`.
3140 /// Length `m + 1`.
3141 row_start: Vec<usize>,
3142 /// Prelude forward values, sized to `tape.n_prelude_ops()`.
3143 prelude_vals: Vec<f64>,
3144 /// Per-summand local forward values, sized to `tape.max_summand_ops()`.
3145 local_vals: Vec<f64>,
3146 /// Reverse-mode adjoint arenas for `eval_jac_g`, sized like the two
3147 /// value arenas above. `gradient_summand` zeroes only the slots a
3148 /// summand actually reaches, so these are allocated once and reused.
3149 local_adj: Vec<f64>,
3150 prelude_adj: Vec<f64>,
3151 /// Whether `eval_jac_g` should take the shared-CSE path too, or stay
3152 /// on the flat per-summand tapes. See [`HYBRID_JAC_MIN_OP_RATIO`] —
3153 /// unlike `eval_g`, the hybrid Jacobian is not a free win.
3154 use_for_jac: bool,
3155 /// Whether `eval_h` routes the constraint block through the shared
3156 /// prelude (issue #557). See [`HYBRID_HESS_MIN_OP_RATIO`].
3157 use_for_hess: bool,
3158 // ---- eval_h (shared-CSE Hessian, issue #557) state. Populated in
3159 // `try_new` after the Hessian coloring exists; cheap enough (a few
3160 // index tables plus one f64 per local op) to build whenever the
3161 // hybrid tape is, so tests can flip `use_for_hess` on directly. ----
3162 /// Forward values of every summand, packed at
3163 /// `local_off[si]..local_off[si + 1]`. One forward pass per `eval_h`
3164 /// fills it; every color then reuses the values, mirroring the flat
3165 /// path's forward-once-per-tape structure.
3166 local_vals_all: Vec<f64>,
3167 /// Prefix offsets into `local_vals_all`, length `n_summands + 1`.
3168 local_off: Vec<usize>,
3169 /// Constraint row of each summand (the inverse of `row_start`), for
3170 /// the `λ[row]` weight lookup.
3171 summand_row: Vec<u32>,
3172 /// Per color: the summands whose variables fall in that color — the
3173 /// hybrid analogue of `con_tape_colors`, inverted so `eval_h` walks
3174 /// exactly the live (color, summand) pairs.
3175 hess_color_summands: Vec<Vec<u32>>,
3176 /// Per color: the prelude slots that color's summands actually reach,
3177 /// ascending — `hess_color_reach[hess_color_reach_off[c]..off[c + 1]]`.
3178 /// Both prelude sweeps run once per color, so walking the whole
3179 /// prelude each time would cost `n_colors × |prelude|` where the
3180 /// op-ratio gate assumes `|prelude|`; iterating the union of the
3181 /// color's `prelude_reach` sets makes the cost proportional to what
3182 /// is used, so the gate does not need an `n_colors` term. Stored as
3183 /// a flat `u32` CSR rather than `Vec<Vec<_>>`: the total is
3184 /// `Σ_c |reach_c|`, which is proportional to the work it drives, and
3185 /// this struct is the one #552 made O(n²) by holding per-color dense
3186 /// arrays.
3187 hess_color_reach: Vec<u32>,
3188 hess_color_reach_off: Vec<usize>,
3189 /// Per-color prelude tangent, sized to `tape.n_prelude_ops()`.
3190 prelude_dot: Vec<f64>,
3191 /// First-/second-order prelude adjoint accumulators. NOT shared
3192 /// with `eval_jac_g`'s `prelude_adj`: these two carry an all-zero-
3193 /// between-colors invariant (`prelude_reverse_directional`'s
3194 /// consume-and-zero contract), while `gradient_summand` zeroes only
3195 /// the slots it is about to use and leaves them dirty afterwards —
3196 /// sharing the buffer would seed a later `eval_h` with a stale
3197 /// Jacobian adjoint.
3198 hess_prelude_adj: Vec<f64>,
3199 prelude_adj_dot: Vec<f64>,
3200 /// Local tangent / second-order adjoint arenas, sized to
3201 /// `tape.max_summand_ops()`.
3202 local_dot: Vec<f64>,
3203 local_adj_dot: Vec<f64>,
3204}
3205
3206/// Flat-to-shared op-count ratio above which `eval_jac_g` switches to the
3207/// shared-CSE prelude.
3208///
3209/// `eval_g` takes the hybrid path unconditionally because it only needs
3210/// *values*: the prelude is swept once for the whole constraint block and
3211/// the saving is the full op-count ratio. The Jacobian is different. Each
3212/// row needs its own gradient, so only the forward sweep can be shared —
3213/// the reverse sweep still walks each summand's `prelude_reach`
3214/// separately, and it pays a per-op cost the flat tape does not: a nested
3215/// `SummandOp` dispatch and an indirected walk over a reach list instead
3216/// of a straight loop over a contiguous `Vec<TapeOp>`.
3217///
3218/// So the hybrid Jacobian wins only when the shared bodies are large
3219/// enough for the halved forward sweep to outweigh that overhead.
3220/// Measured on chain models at CSE redundancy 40, varying the body size
3221/// (`eval_jac_g`, flat → hybrid):
3222///
3223/// | op ratio | 1.94 | 2.20 | 2.84 | 3.53 | 4.20 | 5.16 | 6.35 | 8.00 |
3224/// |---|---|---|---|---|---|---|---|---|
3225/// | speedup | 0.77× | 0.63× | 0.88× | 1.21× | 1.21× | 1.18× | 1.50× | 1.32× |
3226///
3227/// The crossover sits near 3; this gate is set at 4 to keep a margin, so
3228/// a model that does not clearly benefit stays on the flat path. For
3229/// reference `robot_a` (#476) measures 4.03×.
3230const HYBRID_JAC_MIN_OP_RATIO: f64 = 4.0;
3231
3232/// Flat-to-shared op-count ratio above which `eval_h` routes the constraint
3233/// block through the shared-CSE prelude (issue #557).
3234///
3235/// The Hessian shares **both** second-order sweeps of the prelude, not just
3236/// the forward one: the coloring hands every summand of a color the same
3237/// seed vector, so the prelude forward tangent runs once per color, and —
3238/// because reverse-over-tangent is linear in its adjoint seeds — the
3239/// `λ_k`-weighted boundary adjoints of all summands accumulate into one
3240/// unit-weight prelude reverse sweep per color. That is why its crossover
3241/// sits *below* the Jacobian's ([`HYBRID_JAC_MIN_OP_RATIO`], set at 4): the
3242/// Jacobian can only share the forward half, and per-row gradients forbid
3243/// batching its reverse sweeps at all.
3244///
3245/// Measured on chain models at CSE redundancy 40 (m = 20,000, 500 shared
3246/// bodies), varying the body size — the same protocol as the Jacobian
3247/// gate's table (`eval_h`, flat → hybrid). **Median of 5 interleaved
3248/// flat/hybrid pairs per point**, with the observed range, because
3249/// single runs on a shared machine are not reproducible to the precision
3250/// a threshold decision needs — one sample below spans 0.36×–1.14× at a
3251/// single ratio:
3252///
3253/// | op ratio | 1.94 | 2.54 | 3.12 | 3.69 | 4.24 | 5.29 | 6.76 | 8.53 |
3254/// |---|---|---|---|---|---|---|---|---|
3255/// | median speedup | 1.00× | 1.04× | 1.18× | 1.23× | 1.31× | 1.44× | 1.36× | 1.49× |
3256/// | 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 |
3257///
3258/// The gate sits at 3.0: that is the lowest ratio where **every** sample
3259/// wins (by ≥ 10%), whereas at 1.94 and 2.54 the median is within noise of
3260/// break-even and individual runs lose. Setting it there costs only a
3261/// marginal forgone gain — below the gate `eval_h` stays on the flat path
3262/// bit-identically, so a gate placed too high is merely conservative while
3263/// one placed too low risks a real regression. For reference `robot_a`
3264/// (#476) measures 4.03×.
3265const HYBRID_HESS_MIN_OP_RATIO: f64 = 3.0;
3266
3267// `Clone` supports the batched-solve path (pounce#126): one parsed
3268// model is cloned per batch instance (tapes are flat `Vec`s of ops, so
3269// the clone is cheap relative to a solve) and each clone gets its own
3270// bound / starting-point overrides via [`NlTnlp::variant`].
3271#[derive(Debug, Clone)]
3272pub struct NlTnlp {
3273 prob: NlProblem,
3274 /// Per-summand objective tapes (one `Tape` per top-level
3275 /// summand after `split_top_sums`).
3276 obj_tapes: Vec<Tape>,
3277 /// Per-constraint, per-summand tapes. Length `m`; row `i` holds
3278 /// one `Tape` per summand of constraint `i`.
3279 con_tapes: Vec<Vec<Tape>>,
3280 /// Constraint-block tape with a **shared** CSE prelude, used by
3281 /// `eval_g` when the model benefits (see [`ConHybrid`]). `None` keeps
3282 /// `eval_g` on the per-summand `con_tapes` above.
3283 con_hybrid: Option<ConHybrid>,
3284 /// The degree-≤2 objective and rows, evaluated from their constant
3285 /// matrices instead of from a tape (gh #588, Q4). A row with a form here
3286 /// has an **empty** `con_tapes` entry, so every loop over the tapes
3287 /// naturally contributes nothing for it and only the sites that consult
3288 /// `quad` add it back: `eval_f`, `eval_grad_f`, `eval_g`, `eval_jac_g`,
3289 /// `eval_h`, and `hessian_vector_products` — the last of which the design
3290 /// note's list of five omitted, and which is the one where an omission
3291 /// would be silent. Empty for a model with nothing recognized, in which
3292 /// case everything below behaves bit for bit as it did before this
3293 /// existed.
3294 quad: QuadraticStructure,
3295 /// Which entries of `h_irow`/`h_jcol` some *tape* can contribute to.
3296 /// Empty when `quad` is — the pattern is then all tape. The coloring is
3297 /// built over this subset alone, which is what stops one dense quadratic
3298 /// block from forcing the color count to `n` for the benefit of tapes
3299 /// that no longer exist.
3300 h_tape_mask: Vec<bool>,
3301 /// Lower-triangle Hessian sparsity (row >= col), one entry per
3302 /// structurally nonzero second derivative in the Lagrangian.
3303 h_irow: Vec<i32>,
3304 h_jcol: Vec<i32>,
3305 /// Per-row sorted variable indices for the constraint Jacobian.
3306 jac_cols: Vec<Vec<usize>>,
3307 jac_nnz: usize,
3308 /// Per-color seed vector: `seeds[c][k] = 1.0` iff variable `k`
3309 /// is in color `c`, else `0.0`. Each color is a set of
3310 /// variables whose Hessian columns have pairwise-disjoint
3311 /// nonzero rows; one directional H·s product per color
3312 /// recovers all those columns simultaneously. Dense for
3313 /// O(1) lookup in the per-op forward tangent.
3314 seeds: Vec<Vec<f64>>,
3315 /// Per-color decoding table: for each `(row, hess_idx)` entry,
3316 /// scatter `compressed_c[row] -> values[hess_idx]` after the
3317 /// per-color directional product.
3318 decoding: Vec<Vec<ColorWrite>>,
3319 /// For each objective tape: the distinct colors of vars it
3320 /// references. Lets us skip tape × color pairs where the tape
3321 /// has zero overlap with the color's seed.
3322 obj_tape_colors: Vec<Vec<u32>>,
3323 /// Same as `obj_tape_colors` but per constraint × summand.
3324 con_tape_colors: Vec<Vec<Vec<u32>>>,
3325 /// Color of each variable's Hessian column, `u32::MAX` for a column
3326 /// that needs no pass of its own. Kept so
3327 /// [`NlTnlp::veto_ill_conditioned_peels`] can find a peeled column's
3328 /// pass in `compressed`.
3329 var_color: Vec<u32>,
3330 /// Columns peeled out of the conflict structure and given singleton
3331 /// colors. Bounded by `MAX_PEELED_COLS`, usually empty.
3332 peeled_cols: Vec<u32>,
3333 final_x: Option<Vec<Number>>,
3334 final_obj: Number,
3335 /// Converged constraint multipliers (length `m`, original `.nl` row
3336 /// order, user convention), captured from the same `finalize_solution`
3337 /// call as `final_x`. Kept so a frontend can write the `.sol` dual
3338 /// block without re-deriving it from the algorithm's internal `y_c` /
3339 /// `y_d` split and scaling.
3340 final_lambda: Option<Vec<Number>>,
3341 /// Converged bound multipliers (length `n` each, Ipopt's internal
3342 /// convention `z_l, z_u >= 0`), captured with `final_x`. Written as the
3343 /// `ipopt_zL_out` / `ipopt_zU_out` `.sol` suffixes, which is what Pyomo
3344 /// reads for reduced costs.
3345 final_z_l: Option<Vec<Number>>,
3346 final_z_u: Option<Vec<Number>>,
3347 /// Per-row Jacobian accumulator (length n).
3348 scratch_row_grad: Vec<f64>,
3349 /// Scratch buffers for `Tape::hessian_directional` (each sized
3350 /// to `max_tape_n`).
3351 vals_scratch: Vec<f64>,
3352 dot_scratch: Vec<f64>,
3353 adj_scratch: Vec<f64>,
3354 adj_dot_scratch: Vec<f64>,
3355 /// Per-color compressed Hessian-vector results, sized to
3356 /// `prob.n`. Reused across `eval_h` calls but allocated once.
3357 compressed: Vec<Vec<f64>>,
3358 /// Per-direction "carries signal" mask for `hessian_vector_products`,
3359 /// kept here rather than allocated per call. This crate holds an
3360 /// explicit no-per-call-allocation line on the tape sweeps (see
3361 /// `tests/tape_gradient_no_alloc.rs`), and the headline Newton-Krylov
3362 /// use is `k = 1`, where a fresh `Vec` would be pure overhead on every
3363 /// Krylov iteration.
3364 hvp_live: Vec<bool>,
3365 /// Model-derived scaling factors (gh #703), computed on demand by
3366 /// [`NlTnlp::enable_curvature_scaling`] and served through
3367 /// [`TNLP::get_scaling_parameters`]. `None` until asked for, which is
3368 /// the state every solve that does not select `curvature-based` stays
3369 /// in — computing them costs a pass over every stored Hessian entry
3370 /// and nothing else reads them.
3371 curvature_scaling: Option<crate::nl_scaling::CurvatureScaling>,
3372}
3373
3374// ---------------------------------------------------------------------
3375// Human-readable equation rendering (`print equation` in the debugger).
3376//
3377// Turns a parsed constraint back into infix text using the model's
3378// variable / constraint names, so the debugger can show the actual
3379// equation a user wrote — `T_reactor*flow - 300 = 0` — instead of a
3380// bare row index. This is the "print the specific equation, with
3381// names" capability Lee et al. (2024, <https://doi.org/10.69997/sct.147875>)
3382// argue makes equation-oriented model diagnostics actionable.
3383//
3384// The renderer is intentionally separate from the evaluation `Tape`:
3385// tapes are lossy for display (CSEs flattened, externals opaque),
3386// whereas the `Expr` DAG is the faithful source the `.nl` parser built.
3387// ---------------------------------------------------------------------
3388
3389/// Binding strength for parenthesization. Higher binds tighter.
3390const P_ADD: u8 = 10;
3391const P_MUL: u8 = 20;
3392const P_NEG: u8 = 30;
3393const P_POW: u8 = 40;
3394const P_ATOM: u8 = 100;
3395
3396/// Format a numeric literal compactly: integers without a trailing `.0`,
3397/// everything else via the shortest round-tripping `f64` form.
3398fn fmt_num(x: Number) -> String {
3399 if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
3400 format!("{}", x as i64)
3401 } else {
3402 format!("{x}")
3403 }
3404}
3405
3406/// Display label for variable `i`: its `.col` name when present, else
3407/// `x[i]`.
3408fn var_label(i: usize, var_names: &[String]) -> String {
3409 match var_names.get(i) {
3410 Some(s) if !s.is_empty() => s.clone(),
3411 _ => format!("x[{i}]"),
3412 }
3413}
3414
3415/// Precedence of an expression's top operator (for child wrapping).
3416fn expr_prec(e: &Expr) -> u8 {
3417 match e {
3418 Expr::Binary(BinOp::Add, ..) | Expr::Binary(BinOp::Sub, ..) | Expr::Sum(_) => P_ADD,
3419 Expr::Binary(BinOp::Mul, ..) | Expr::Binary(BinOp::Div, ..) => P_MUL,
3420 Expr::Unary(UnaryOp::Neg, _) => P_NEG,
3421 Expr::Binary(BinOp::Pow, ..) => P_POW,
3422 Expr::Cse(inner) => expr_prec(inner),
3423 // Everything else renders as an atom / `f(...)` form.
3424 _ => P_ATOM,
3425 }
3426}
3427
3428/// Render an expression as infix text, using `var_names` for variable
3429/// labels where available (`x[i]` otherwise).
3430///
3431/// The debugger reaches the renderer through the constraint/objective
3432/// walkers; this is the bare entry point for a caller that holds an [`Expr`]
3433/// directly — notably the Python `NlExpr.__repr__` (issue #469), where being
3434/// able to *see* the expression you just built is most of the debugging
3435/// story.
3436///
3437/// `Cse` bodies are inlined at every occurrence, so the output of a
3438/// heavily-shared DAG can be far larger than the DAG itself. Callers
3439/// rendering user-built expressions should bound the input first.
3440pub fn render_expression(e: &Expr, var_names: &[String]) -> String {
3441 render_expr(e, var_names, &[])
3442}
3443
3444/// Render `e`, wrapping in parentheses iff its precedence is looser than
3445/// `min_prec`.
3446fn render_prec(e: &Expr, min_prec: u8, vn: &[String], funcs: &[ImportedFunc]) -> String {
3447 let s = render_expr(e, vn, funcs);
3448 if expr_prec(e) < min_prec {
3449 format!("({s})")
3450 } else {
3451 s
3452 }
3453}
3454
3455fn unary_name(op: UnaryOp) -> &'static str {
3456 match op {
3457 UnaryOp::Neg => "-",
3458 UnaryOp::Sqrt => "sqrt",
3459 UnaryOp::Log => "log",
3460 UnaryOp::Exp => "exp",
3461 UnaryOp::Abs => "abs",
3462 UnaryOp::Sin => "sin",
3463 UnaryOp::Cos => "cos",
3464 UnaryOp::Log10 => "log10",
3465 UnaryOp::Tan => "tan",
3466 UnaryOp::Atan => "atan",
3467 UnaryOp::Acos => "acos",
3468 UnaryOp::Sinh => "sinh",
3469 UnaryOp::Cosh => "cosh",
3470 UnaryOp::Tanh => "tanh",
3471 UnaryOp::Asin => "asin",
3472 UnaryOp::Acosh => "acosh",
3473 UnaryOp::Asinh => "asinh",
3474 UnaryOp::Atanh => "atanh",
3475 UnaryOp::Erf => "erf",
3476 // Spelled as the operation, not as GAMS `entropy` (which is -x·ln x):
3477 // the rendered text is read by humans debugging a model and must not
3478 // imply a sign the op does not have.
3479 UnaryOp::XLogX => "xlogx",
3480 }
3481}
3482
3483fn cmp_sym(op: CmpOp) -> &'static str {
3484 match op {
3485 CmpOp::Lt => "<",
3486 CmpOp::Le => "<=",
3487 CmpOp::Eq => "==",
3488 CmpOp::Ge => ">=",
3489 CmpOp::Gt => ">",
3490 CmpOp::Ne => "!=",
3491 }
3492}
3493
3494/// Append an additive sub-term with a tidy sign: a rendered term that
3495/// begins with `-` is folded into a ` - ` separator, so `a + -b` reads as
3496/// `a - b`. The identity `a + (-b …) = a - b …` keeps this exact even when
3497/// the term is itself a sum. The first term is emitted verbatim.
3498fn push_additive(out: &mut String, rendered: &str, first: bool) {
3499 if first {
3500 out.push_str(rendered);
3501 } else if let Some(rest) = rendered.strip_prefix('-') {
3502 out.push_str(" - ");
3503 out.push_str(rest);
3504 } else {
3505 out.push_str(" + ");
3506 out.push_str(rendered);
3507 }
3508}
3509
3510/// Render an [`Expr`] DAG to infix text using model names.
3511fn render_expr(e: &Expr, vn: &[String], funcs: &[ImportedFunc]) -> String {
3512 match e {
3513 Expr::Const(c) => fmt_num(*c),
3514 Expr::Var(i) => var_label(*i, vn),
3515 Expr::Binary(op, l, r) => match op {
3516 BinOp::Add => {
3517 let mut s = render_prec(l, P_ADD, vn, funcs);
3518 push_additive(&mut s, &render_prec(r, P_ADD, vn, funcs), false);
3519 s
3520 }
3521 // Right operand at P_ADD+1 so `a - (b - c)` keeps its parens.
3522 BinOp::Sub => format!(
3523 "{} - {}",
3524 render_prec(l, P_ADD, vn, funcs),
3525 render_prec(r, P_ADD + 1, vn, funcs)
3526 ),
3527 BinOp::Mul => format!(
3528 "{}*{}",
3529 render_prec(l, P_MUL, vn, funcs),
3530 render_prec(r, P_MUL, vn, funcs)
3531 ),
3532 BinOp::Div => format!(
3533 "{}/{}",
3534 render_prec(l, P_MUL, vn, funcs),
3535 render_prec(r, P_MUL + 1, vn, funcs)
3536 ),
3537 // Pow is right-associative: tighten the left operand instead.
3538 BinOp::Pow => format!(
3539 "{}^{}",
3540 render_prec(l, P_POW + 1, vn, funcs),
3541 render_prec(r, P_POW, vn, funcs)
3542 ),
3543 BinOp::Atan2 => format!(
3544 "atan2({}, {})",
3545 render_expr(l, vn, funcs),
3546 render_expr(r, vn, funcs)
3547 ),
3548 BinOp::CEntropy => format!(
3549 "centropy({}, {})",
3550 render_expr(l, vn, funcs),
3551 render_expr(r, vn, funcs)
3552 ),
3553 },
3554 Expr::Unary(UnaryOp::Neg, a) => format!("-{}", render_prec(a, P_NEG, vn, funcs)),
3555 Expr::Unary(op, a) => format!("{}({})", unary_name(*op), render_expr(a, vn, funcs)),
3556 Expr::Sum(xs) => {
3557 if xs.is_empty() {
3558 "0".to_string()
3559 } else {
3560 let mut s = String::new();
3561 for (k, x) in xs.iter().enumerate() {
3562 push_additive(&mut s, &render_prec(x, P_ADD, vn, funcs), k == 0);
3563 }
3564 s
3565 }
3566 }
3567 Expr::Cse(inner) => render_expr(inner, vn, funcs),
3568 Expr::Funcall { id, args } => {
3569 let name = funcs
3570 .iter()
3571 .find(|f| f.id == *id)
3572 .map(|f| f.name.clone())
3573 .unwrap_or_else(|| format!("extern#{id}"));
3574 let parts: Vec<String> = args
3575 .iter()
3576 .map(|a| match a {
3577 FuncallArg::Real(x) => render_expr(x, vn, funcs),
3578 FuncallArg::Str(s) => format!("{s:?}"),
3579 })
3580 .collect();
3581 format!("{name}({})", parts.join(", "))
3582 }
3583 Expr::Compare(op, a, b) => format!(
3584 "({} {} {})",
3585 render_expr(a, vn, funcs),
3586 cmp_sym(*op),
3587 render_expr(b, vn, funcs)
3588 ),
3589 Expr::And(a, b) => format!(
3590 "({} && {})",
3591 render_expr(a, vn, funcs),
3592 render_expr(b, vn, funcs)
3593 ),
3594 Expr::Or(a, b) => format!(
3595 "({} || {})",
3596 render_expr(a, vn, funcs),
3597 render_expr(b, vn, funcs)
3598 ),
3599 Expr::Not(a) => format!("!({})", render_expr(a, vn, funcs)),
3600 Expr::Cond { cond, then_, else_ } => format!(
3601 "if({}, {}, {})",
3602 render_expr(cond, vn, funcs),
3603 render_expr(then_, vn, funcs),
3604 render_expr(else_, vn, funcs)
3605 ),
3606 Expr::MinList(xs) => format!(
3607 "min({})",
3608 xs.iter()
3609 .map(|x| render_expr(x, vn, funcs))
3610 .collect::<Vec<_>>()
3611 .join(", ")
3612 ),
3613 Expr::MaxList(xs) => format!(
3614 "max({})",
3615 xs.iter()
3616 .map(|x| render_expr(x, vn, funcs))
3617 .collect::<Vec<_>>()
3618 .join(", ")
3619 ),
3620 }
3621}
3622
3623/// Render the affine `Σ cᵢ·xᵢ` part with tidy signs (`a - 2*b`, not
3624/// `a + -2*b`). Returns `""` when there are no linear terms.
3625fn render_linear(linear: &[(usize, Number)], vn: &[String]) -> String {
3626 let mut out = String::new();
3627 // The `.nl` linear part carries an entry for every variable in the
3628 // row's Jacobian, including a 0 coefficient for variables that appear
3629 // only *nonlinearly* (they're rendered in the nonlinear part). Skip
3630 // those zeros so the equation reads as written, not as a sparsity map.
3631 let mut first = true;
3632 for (var, coef) in linear {
3633 if *coef == 0.0 {
3634 continue;
3635 }
3636 let neg = *coef < 0.0;
3637 let mag = coef.abs();
3638 let term = if mag == 1.0 {
3639 var_label(*var, vn)
3640 } else {
3641 format!("{}*{}", fmt_num(mag), var_label(*var, vn))
3642 };
3643 if first {
3644 if neg {
3645 out.push('-');
3646 }
3647 out.push_str(&term);
3648 first = false;
3649 } else {
3650 out.push_str(if neg { " - " } else { " + " });
3651 out.push_str(&term);
3652 }
3653 }
3654 out
3655}
3656
3657/// Render the constraint body (linear + nonlinear parts combined).
3658fn render_body(linear: &[(usize, Number)], nonlinear: &Expr, prob: &NlProblem) -> String {
3659 let mut s = render_linear(linear, &prob.var_names);
3660 let nl_is_zero = matches!(nonlinear, Expr::Const(c) if *c == 0.0);
3661 if !nl_is_zero {
3662 let nl = render_prec(nonlinear, P_ADD, &prob.var_names, &prob.imported_funcs);
3663 if s.is_empty() {
3664 s = nl;
3665 } else {
3666 push_additive(&mut s, &nl, false);
3667 }
3668 }
3669 if s.is_empty() {
3670 s = "0".to_string();
3671 }
3672 s
3673}
3674
3675/// Render constraint `k` as a full relation, e.g. `mass_in - mass_out = 0`
3676/// or `0 <= T_reactor <= 500`. Bounds outside ±1e19 are treated as
3677/// infinite (AMPL's convention), matching [`TNLPAdapter`]'s classifier.
3678pub fn render_constraint_equation(prob: &NlProblem, k: usize) -> String {
3679 // Diagnostic path: a recognized body has no tree, so this is one of
3680 // the places that pays to rebuild one. It runs once per rendered row.
3681 let body = render_body(&prob.con_linear[k], &prob.con_expr(k), prob);
3682 let lo = prob.g_l[k];
3683 let hi = prob.g_u[k];
3684 const INF: Number = 1.0e19;
3685 let has_lo = lo > -INF;
3686 let has_hi = hi < INF;
3687 match (has_lo, has_hi) {
3688 (true, true) if lo == hi => format!("{body} = {}", fmt_num(lo)),
3689 (true, true) => format!("{} <= {body} <= {}", fmt_num(lo), fmt_num(hi)),
3690 (true, false) => format!("{body} >= {}", fmt_num(lo)),
3691 (false, true) => format!("{body} <= {}", fmt_num(hi)),
3692 (false, false) => format!("{body} (free)"),
3693 }
3694}
3695
3696/// Render every constraint to text, index-aligned to `g` (original `.nl`
3697/// row order). Used to build the debugger's static equation book.
3698pub fn render_all_constraint_equations(prob: &NlProblem) -> Vec<String> {
3699 (0..prob.m)
3700 .map(|k| render_constraint_equation(prob, k))
3701 .collect()
3702}
3703
3704/// Structural sparsity of the constraint Jacobian as flat 0-based
3705/// triplets `(irow, jcol)`: one pair per variable that constraint `k`
3706/// structurally depends on — the union of its linear support and the
3707/// `Var(i)` indices appearing anywhere in its nonlinear tree
3708/// ([`collect_vars`]). Sorted and deduplicated within each row.
3709///
3710/// This is the input to the debugger's Dulmage–Mendelsohn
3711/// structural-rank check (`diagnose`), which names the over-determined
3712/// (candidate redundant / inconsistent) equations and under-determined
3713/// variables. Naming the dependent rows — rather than reporting
3714/// "equations 3, 15, …" — is the roadblock Lee et al. (2024) flag for
3715/// equation-oriented model debugging. See
3716/// <https://doi.org/10.69997/sct.147875>.
3717pub fn constraint_jacobian_sparsity(prob: &NlProblem) -> (Vec<Index>, Vec<Index>) {
3718 let mut irow: Vec<Index> = Vec::new();
3719 let mut jcol: Vec<Index> = Vec::new();
3720 let mut support: BTreeSet<usize> = BTreeSet::new();
3721 for k in 0..prob.m {
3722 support.clear();
3723 for &(j, _coef) in &prob.con_linear[k] {
3724 support.insert(j);
3725 }
3726 prob.con_nonlinear[k].collect_vars(&mut support);
3727 for &j in &support {
3728 irow.push(k as Index);
3729 jcol.push(j as Index);
3730 }
3731 }
3732 (irow, jcol)
3733}
3734
3735/// Flatten an additive expression tree into independent summand
3736/// expressions, each of which becomes its own Hessian tape.
3737///
3738/// This is the linchpin of the colored-AD Hessian: `eval_h` walks
3739/// each summand tape once *per color the summand touches*, so the
3740/// cost is `Σ_summand (tape_len · colors_touched)`. Keeping summands
3741/// small (few variables → few colors) is what makes a sparse Hessian
3742/// cheap. A single fused tape spanning all `n` variables, by
3743/// contrast, is walked once per color → `O(n · tape_len)`, which on a
3744/// dense `n`-variable objective is `O(n³)` (observed: 47 s on the
3745/// 1000-var `sensors`, whose objective is `-(Σ 10⁶ pairwise terms)`).
3746///
3747/// We therefore descend through the *affine* envelope of the sum, not
3748/// just `+`/`Sum`:
3749///
3750/// * `Neg(x)` → split `x`, negate each summand
3751/// * `Sub(l, r)` → split `l`; split `r`, negate each summand
3752/// * `c * x` / `x * c` → split `x`, scale each summand by `c`
3753/// * `x / c` → split `x`, scale each summand by `1/c`
3754///
3755/// so that an objective like `-(Σ …)` or `0.5·(Σ …)` (the usual
3756/// least-squares / max-entropy shapes) still decomposes to its leaf
3757/// terms instead of collapsing into one giant tape. The carried
3758/// `factor` is materialised onto each leaf only when it differs from
3759/// `1` (as `Neg` for `-1`, else a `Const·term` multiply), so the math
3760/// is unchanged and the per-summand op count grows by at most one.
3761fn split_top_sums(expr: &Expr) -> Vec<Expr> {
3762 let mut out = Vec::new();
3763 fn push_leaf(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
3764 if factor == 1.0 {
3765 out.push(e.clone());
3766 } else if factor == -1.0 {
3767 out.push(Expr::Unary(UnaryOp::Neg, Box::new(e.clone())));
3768 } else {
3769 out.push(Expr::Binary(
3770 BinOp::Mul,
3771 Box::new(Expr::Const(factor)),
3772 Box::new(e.clone()),
3773 ));
3774 }
3775 }
3776 fn go(e: &Expr, factor: f64, out: &mut Vec<Expr>) {
3777 match e {
3778 Expr::Sum(terms) => {
3779 for t in terms {
3780 go(t, factor, out);
3781 }
3782 }
3783 Expr::Binary(BinOp::Add, l, r) => {
3784 go(l, factor, out);
3785 go(r, factor, out);
3786 }
3787 Expr::Binary(BinOp::Sub, l, r) => {
3788 go(l, factor, out);
3789 go(r, -factor, out);
3790 }
3791 Expr::Unary(UnaryOp::Neg, x) => {
3792 go(x, -factor, out);
3793 }
3794 // Affine scaling: distribute a constant coefficient into
3795 // the summands so a leading `c·(Σ …)` still splits.
3796 Expr::Binary(BinOp::Mul, l, r) => match (l.as_ref(), r.as_ref()) {
3797 (Expr::Const(c), _) => go(r, factor * c, out),
3798 (_, Expr::Const(c)) => go(l, factor * c, out),
3799 _ => push_leaf(e, factor, out),
3800 },
3801 Expr::Binary(BinOp::Div, l, r) => match r.as_ref() {
3802 Expr::Const(c) if *c != 0.0 => go(l, factor / c, out),
3803 _ => push_leaf(e, factor, out),
3804 },
3805 _ => push_leaf(e, factor, out),
3806 }
3807 }
3808 go(expr, 1.0, &mut out);
3809 if out.is_empty() {
3810 out.push(Expr::Const(0.0));
3811 }
3812 out
3813}
3814
3815/// Greedy column coloring of a symmetric sparsity pattern stored
3816/// as lower-triangle pairs.
3817///
3818/// Builds the column-intersection graph: columns `c1` and `c2` are
3819/// adjacent iff there exists a row `r` with `H[r, c1] != 0` and
3820/// `H[r, c2] != 0`. A distance-1 greedy coloring on this graph
3821/// satisfies the direct-recovery condition for symmetric Hessians
3822/// (Coleman-Moré): for any color, the columns it contains have
3823/// pairwise disjoint row supports, so a single H·s product
3824/// recovers them all unambiguously.
3825///
3826/// Returns `(var_color, n_colors)` where `var_color[k]` is the
3827/// color assigned to variable `k`, or `u32::MAX` for variables
3828/// not in any Hessian pair (they contribute nothing and don't
3829/// need a color).
3830/// A column is treated as **dense** — and peeled out of the coloring —
3831/// once its nonzero-row count exceeds `DENSE_COL_FACTOR` times the
3832/// average, but never below `DENSE_COL_MIN`. Both guards matter: the
3833/// factor keeps uniformly-dense Hessians (where every column looks like
3834/// every other) on the plain coloring path, and the absolute floor stops
3835/// a very sparse average from declaring a 10-entry column "dense".
3836const DENSE_COL_FACTOR: usize = 16;
3837const DENSE_COL_MIN: usize = 32;
3838/// Largest relative error a peeled column may inflict on the smallest
3839/// entry recovered from its pass before
3840/// [`NlTnlp::veto_ill_conditioned_peels`] un-peels it.
3841///
3842/// The ratio is a worst-case bound — the pass's roundoff floor over the
3843/// smallest entry read out of it — and it is a *loose* one, by an amount
3844/// that varies per column: `rocket_12800` bounds at 2e-9 and measures 3e-14
3845/// against an uncompressed reference, while `orthregd` bounds at 3e-8 and
3846/// measures 4e-16. The cut is therefore calibrated on the corpus, not
3847/// derived, and the corpus leaves only a narrow gap to sit in: over the 56
3848/// models that peel anything, the highest bound on a column that recovers
3849/// its entries to machine precision is 2.9e-8 (`orthregd`), and the lowest
3850/// bound on one of `cho_parmest`'s harmful columns is 8.3e-8 — a factor of
3851/// 2.8 apart, with `cho_parmest`'s worst running to 5e-2.
3852///
3853/// 1e-8 sits below both, which deliberately buys correctness with speed:
3854/// the errors are asymmetric, since a false veto costs one model a coloring
3855/// (measured: five sub-second `orth*` models pay 2-3.5x, worst case +0.32s)
3856/// while a missed veto costs a solve its certificate. Five of the 56 take a
3857/// veto they do not need; none takes a wrong answer. Raising this constant
3858/// to buy those five back would put the cut inside a 2.8x window measured
3859/// on two model families, which is not a margin worth trading a certificate
3860/// for.
3861const PEEL_MAX_REL_ERR: f64 = 1e-8;
3862/// Hard cap on how many columns get peeled, applied on top of the
3863/// pay-for-itself rule in [`select_peeled_cols`].
3864const MAX_PEELED_COLS: usize = 256;
3865
3866/// Lower bound on the color count that results from peeling `peeled`.
3867///
3868/// Peeling costs one color per peeled column. On what remains, any row
3869/// with `d` surviving entries makes those `d` columns pairwise
3870/// conflicting, so the greedy walk needs at least `d` colors. Hence
3871/// `|peeled| + max surviving row degree` is a lower bound on the total,
3872/// computable in one O(nnz) pass — no coloring required.
3873///
3874/// The bound is what makes the choice decidable at all: the plain
3875/// coloring cannot be run as a comparison baseline, because on the
3876/// one-dense-row case the plain walk is itself O(n^2) — precisely the
3877/// blowup peeling exists to avoid.
3878fn peel_color_bound(n: usize, lower_pairs: &[(usize, usize)], peeled: &[bool]) -> usize {
3879 let mut deg = vec![0usize; n];
3880 for &(i, j) in lower_pairs {
3881 if peeled[i] || peeled[j] {
3882 continue;
3883 }
3884 deg[j] += 1;
3885 if i != j {
3886 deg[i] += 1;
3887 }
3888 }
3889 let n_peeled = peeled.iter().filter(|&&p| p).count();
3890 n_peeled + deg.iter().copied().max().unwrap_or(0)
3891}
3892
3893/// Choose which of the candidate dense columns to actually peel.
3894///
3895/// Evaluates [`peel_color_bound`] for peeling nothing and for peeling the
3896/// top `k` candidates by degree, over a doubling ladder of `k` up to
3897/// [`MAX_PEELED_COLS`], and keeps the best. Ties go to the smaller `k`,
3898/// so peeling has to earn its colors.
3899///
3900/// **Why not just truncate an over-long candidate list.** Cutting the
3901/// candidates down to `MAX_PEELED_COLS` is not a damage bound: the
3902/// columns that miss the cut stay in the conflict structure, so the base
3903/// color count is untouched and the singleton colors are pure addition.
3904/// On disjoint 50x50 blocks scattered through a 200k-variable Hessian
3905/// that colors to `50 + 256` where a plain walk needs 50 — a 6x
3906/// regression in exactly the quantity peeling exists to reduce. The
3907/// bound above sees it: peeling `k` of several thousand equal-degree
3908/// columns leaves the surviving max degree unchanged, so every `k > 0`
3909/// scores strictly worse than peeling nothing.
3910///
3911/// **Why not a simple degree rule.** "Peel only columns denser than some
3912/// fraction of `n`" would refuse three rows of degree 10,000 in a
3913/// 200,000-variable model, where peeling three columns takes the
3914/// coloring from >= 10,000 down to a handful. The win depends on what
3915/// peeling leaves behind, not on the peeled column's degree alone.
3916fn select_peeled_cols(
3917 n: usize,
3918 lower_pairs: &[(usize, usize)],
3919 deg: &[usize],
3920 mut candidates: Vec<usize>,
3921) -> Vec<usize> {
3922 if candidates.is_empty() {
3923 return candidates;
3924 }
3925 // Worst offenders first; they remove the most conflict per color spent.
3926 candidates.sort_unstable_by(|&a, &b| deg[b].cmp(°[a]).then(a.cmp(&b)));
3927 candidates.truncate(MAX_PEELED_COLS);
3928
3929 let mut mask = vec![false; n];
3930 let mut best_k = 0usize;
3931 // Peeling nothing: the bound is just the largest row degree.
3932 let mut best_bound = peel_color_bound(n, lower_pairs, &mask);
3933
3934 // Doubling ladder 1, 2, 4, ... capped at the candidate count, so the
3935 // cost is O(nnz log MAX_PEELED_COLS) rather than O(nnz) per k. The
3936 // mask only ever gains entries, so each step just marks the new slice.
3937 let mut marked = 0usize;
3938 let mut k = 1usize;
3939 loop {
3940 let k_now = k.min(candidates.len());
3941 for &j in &candidates[marked..k_now] {
3942 mask[j] = true;
3943 }
3944 marked = k_now;
3945 let bound = peel_color_bound(n, lower_pairs, &mask);
3946 if bound < best_bound {
3947 best_bound = bound;
3948 best_k = k_now;
3949 }
3950 if k_now == candidates.len() {
3951 break;
3952 }
3953 k *= 2;
3954 }
3955
3956 candidates.truncate(best_k);
3957 candidates
3958}
3959
3960/// Greedy distance-1 coloring of the Hessian's column-intersection
3961/// graph, with **dense columns peeled out**.
3962///
3963/// Returns `(var_color, n_colors, peeled)`. `var_color[j] == u32::MAX`
3964/// marks a column that needs no directional product of its own: either
3965/// it has no Hessian entries at all, or every entry it has is recovered
3966/// from a peeled column's pass (see below).
3967///
3968/// # Why peeling
3969///
3970/// The plain coloring rule is "two columns may share a color when they
3971/// have no common nonzero row". A single **dense row** — one variable
3972/// multiplying a sum over all the others, a total-cost variable, a
3973/// shared design parameter — puts a nonzero in *every* column at that
3974/// row, so every pair of columns conflicts and the greedy walk hands out
3975/// `n` colors for a Hessian that may have only ~3n nonzeros. Since
3976/// `NlTnlp` holds `n_colors × n` dense `seeds` and `compressed` arrays,
3977/// that turns into O(n²) memory (6.4 GB at n = 20,000) and O(n²) work
3978/// per `eval_h`, on a problem whose Hessian is perfectly sparse.
3979///
3980/// Peeling exploits the Hessian's symmetry. Give a dense column `d` its
3981/// own singleton color: one directional product with seed `e_d` recovers
3982/// the whole of column `d` exactly. Every pair `(d, j)` — row `d`,
3983/// column `j` — is then already known, because `H[d, j] == H[j, d]` sits
3984/// at row `j` of that same pass. So row `d` no longer constrains any
3985/// other column's color and is dropped from the conflict structure, and
3986/// the remaining columns colour on their genuine sparsity. On the
3987/// one-dense-row case above this takes `n_colors` from `n` to a handful.
3988///
3989/// # What peeling costs, and `peel_veto`
3990///
3991/// Recovering `H[d, j]` from column `d`'s pass is exact in real
3992/// arithmetic but not in floating point: the pass is accumulated at the
3993/// scale of the whole dense column, so every entry read out of it carries
3994/// an absolute roundoff floor of about `eps * ||H(:, d)||`, where the
3995/// ordinary path — column `j`'s own pass — would have left a floor of
3996/// about `eps * |H[d, j]|`. The two agree to the last bit whenever the
3997/// dense column is well scaled, and they do on every peel-firing model in
3998/// the benchmark corpus but one. Where a peeled column spans a wide
3999/// dynamic range, though, that floor swamps its small entries: a column
4000/// holding both 2.8e5 and 5.6e-4 loses about nine digits on the latter.
4001///
4002/// Structure cannot see this — it is a property of the values — so
4003/// [`NlTnlp::veto_ill_conditioned_peels`] probes the peeled columns once
4004/// and passes the offenders back here in `peel_veto`, which bars them
4005/// from being peeled again. A vetoed column is colored normally, its row
4006/// returns to the conflict structure, and its entries go back to the
4007/// accurate path.
4008fn greedy_hessian_coloring(
4009 n: usize,
4010 lower_pairs: &[(usize, usize)],
4011 peel_veto: &[bool],
4012) -> (Vec<u32>, usize, Vec<bool>) {
4013 if n == 0 {
4014 return (Vec::new(), 0, Vec::new());
4015 }
4016
4017 // Column degrees in the FULL (symmetric) Hessian: pair (i, j) with
4018 // i >= j contributes row i to column j and row j to column i; a
4019 // diagonal contributes once.
4020 let mut deg = vec![0usize; n];
4021 for &(i, j) in lower_pairs {
4022 deg[j] += 1;
4023 if i != j {
4024 deg[i] += 1;
4025 }
4026 }
4027
4028 // Pick the dense columns to peel.
4029 let total: usize = deg.iter().sum();
4030 let threshold = DENSE_COL_MIN.max(DENSE_COL_FACTOR.saturating_mul(total / n));
4031 let mut peeled = vec![false; n];
4032 let candidates: Vec<usize> = (0..n)
4033 .filter(|&j| deg[j] > threshold && !peel_veto.get(j).copied().unwrap_or(false))
4034 .collect();
4035 let dense = select_peeled_cols(n, lower_pairs, °, candidates);
4036 for &j in &dense {
4037 peeled[j] = true;
4038 }
4039
4040 // Conflict structure over the *non-peeled* columns only. Pairs with
4041 // a peeled endpoint are recovered from that endpoint's own pass, so
4042 // they neither need a color nor constrain one.
4043 let mut col_rows: Vec<Vec<u32>> = vec![Vec::new(); n];
4044 let mut row_cols: Vec<Vec<u32>> = vec![Vec::new(); n];
4045 for &(i, j) in lower_pairs {
4046 if peeled[i] || peeled[j] {
4047 continue;
4048 }
4049 col_rows[j].push(i as u32);
4050 row_cols[i].push(j as u32);
4051 if i != j {
4052 col_rows[i].push(j as u32);
4053 row_cols[j].push(i as u32);
4054 }
4055 }
4056
4057 let mut var_color = vec![u32::MAX; n];
4058 let mut forbidden = vec![u32::MAX; n + 1];
4059 let mut n_colors: u32 = 0;
4060
4061 for j in 0..n {
4062 // Peeled columns are colored below; a column with no surviving
4063 // Hessian entries needs no color at all.
4064 if peeled[j] || col_rows[j].is_empty() {
4065 continue;
4066 }
4067 // Mark colors used by any column sharing a row with `j`.
4068 // Row-of-col -> col-in-row visit pattern collects all
4069 // distance-1 neighbors in the column-intersection graph.
4070 for &r in &col_rows[j] {
4071 for &c in &row_cols[r as usize] {
4072 if c as usize == j {
4073 continue;
4074 }
4075 let cc = var_color[c as usize];
4076 if cc != u32::MAX {
4077 forbidden[cc as usize] = j as u32;
4078 }
4079 }
4080 }
4081 // First color not stamped with `j as u32`.
4082 let mut chosen: u32 = 0;
4083 while (chosen as usize) < forbidden.len() && forbidden[chosen as usize] == j as u32 {
4084 chosen += 1;
4085 }
4086 var_color[j] = chosen;
4087 if chosen + 1 > n_colors {
4088 n_colors = chosen + 1;
4089 }
4090 }
4091
4092 // One singleton color per peeled column, appended after the shared
4093 // ones so the non-peeled numbering is untouched.
4094 for &j in &dense {
4095 var_color[j] = n_colors;
4096 n_colors += 1;
4097 }
4098
4099 (var_color, n_colors as usize, peeled)
4100}
4101
4102/// Everything downstream of the Hessian coloring: seed vectors, the
4103/// per-color decode table, the per-tape color sets, and the shared-CSE
4104/// per-color summand / prelude-reach tables.
4105///
4106/// Split out of [`NlTnlp::new`] because
4107/// [`NlTnlp::veto_ill_conditioned_peels`] may have to build it a second
4108/// time, with a peel veto in hand, once it has seen real Hessian values.
4109fn build_color_tables(
4110 n: usize,
4111 m: usize,
4112 lower_pairs: &[(usize, usize)],
4113 tape_mask: &[bool],
4114 peel_veto: &[bool],
4115 obj_tapes: &[Tape],
4116 con_tapes: &[Vec<Tape>],
4117 con_hybrid: Option<&mut ConHybrid>,
4118) -> ColorTables {
4119 // Hessian column coloring. The chromatic number of the
4120 // column-intersection graph bounds how many directional
4121 // Hessian-vector products we need per `eval_h` call —
4122 // typically O(stencil) for PDE-mesh problems.
4123 //
4124 // Only the entries a *tape* can write take part. `tape_mask` is empty
4125 // when every entry is a tape entry (no quadratic structure), which is
4126 // the pre-#588 behaviour bit for bit; otherwise the quadratic forms'
4127 // entries are excluded, since they are scattered from their stored
4128 // values and never read out of a directional product. A variable that
4129 // appears only in quadratic rows then has no color at all
4130 // (`u32::MAX`) — `greedy_hessian_coloring` already declines to color a
4131 // column with no surviving entries.
4132 let colored_pairs: Vec<(usize, usize)>;
4133 let color_input: &[(usize, usize)] = if tape_mask.is_empty() {
4134 lower_pairs
4135 } else {
4136 colored_pairs = lower_pairs
4137 .iter()
4138 .zip(tape_mask)
4139 .filter_map(|(p, &t)| t.then_some(*p))
4140 .collect();
4141 &colored_pairs
4142 };
4143 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, color_input, peel_veto);
4144
4145 // Per-color seed vectors (dense for O(1) Var lookup in
4146 // `Tape::hessian_directional`).
4147 let mut seeds: Vec<Vec<f64>> = vec![vec![0.0; n]; n_colors];
4148 for (k, &c) in var_color.iter().enumerate() {
4149 if c != u32::MAX {
4150 seeds[c as usize][k] = 1.0;
4151 }
4152 }
4153
4154 // Per-color decoding table. For each lower-tri pair (i, j)
4155 // with i >= j, the entry belongs to column j's color: after
4156 // computing compressed_{c_j} = (H · s_{c_j}), the value at
4157 // row i is exactly H[i, j] (coloring guarantees no other
4158 // column in c_j has a nonzero at row i).
4159 // Built straight from `lower_pairs`, which is sorted, so each
4160 // color's table is in ascending `hess_idx` order and the decode
4161 // scatter walks `values` forward instead of hopping (the old
4162 // build drained a `HashMap`, whose iteration order is arbitrary).
4163 let mut decoding: Vec<Vec<ColorWrite>> = vec![Vec::new(); n_colors];
4164 for (idx, &(i, j)) in lower_pairs.iter().enumerate() {
4165 // An entry no tape writes has no pass to be decoded out of; the
4166 // quadratic scatter already put its value there.
4167 if !tape_mask.is_empty() && !tape_mask[idx] {
4168 continue;
4169 }
4170 // Which directional product recovers H[i, j]? Column `j`'s,
4171 // read at row `i` — except when `i` is a peeled column and
4172 // `j` is not: then `j` may have no color of its own, and the
4173 // entry is already in column `i`'s pass at row `j`, since
4174 // H[i, j] == H[j, i].
4175 let (c, row) = if peeled[i] && !peeled[j] {
4176 (var_color[i], j)
4177 } else {
4178 (var_color[j], i)
4179 };
4180 debug_assert!(
4181 c != u32::MAX,
4182 "Hessian pair ({i}, {j}) at index {idx} has no color"
4183 );
4184 decoding[c as usize].push(ColorWrite {
4185 row: row as u32,
4186 hess_idx: idx as u32,
4187 });
4188 }
4189
4190 // Per-tape distinct color set: for each tape, the colors
4191 // its variables fall into. `eval_h` loops over only these
4192 // (tape, color) pairs instead of n_tapes × n_colors.
4193 let tape_colors = |t: &Tape| -> Vec<u32> {
4194 let mut s: Vec<u32> = t
4195 .variables()
4196 .into_iter()
4197 .map(|v| var_color[v])
4198 .filter(|&c| c != u32::MAX)
4199 .collect();
4200 s.sort_unstable();
4201 s.dedup();
4202 s
4203 };
4204 let obj_tape_colors: Vec<Vec<u32>> = obj_tapes.iter().map(tape_colors).collect();
4205 let con_tape_colors: Vec<Vec<Vec<u32>>> = con_tapes
4206 .iter()
4207 .map(|row| row.iter().map(tape_colors).collect())
4208 .collect();
4209
4210 // Shared-CSE Hessian tables (issue #557): the per-color summand
4211 // lists, row lookup, and packed forward-value arena `eval_h`'s
4212 // hybrid path walks. Built whenever the hybrid tape is — not just
4213 // above the gate — so flipping `use_for_hess` on (tests, the
4214 // force env var) needs no extra setup; the cost is one f64 per
4215 // local op plus small index tables.
4216 if let Some(h) = con_hybrid {
4217 let n_sum = h.tape.n_summands();
4218 let mut local_off: Vec<usize> = Vec::with_capacity(n_sum + 1);
4219 let mut acc = 0usize;
4220 for s in &h.tape.summands {
4221 local_off.push(acc);
4222 acc += s.ops.len();
4223 }
4224 local_off.push(acc);
4225 h.local_vals_all = vec![0.0; acc];
4226 h.local_off = local_off;
4227
4228 let mut summand_row = vec![0u32; n_sum];
4229 for i in 0..m {
4230 for si in h.row_start[i]..h.row_start[i + 1] {
4231 summand_row[si] = i as u32;
4232 }
4233 }
4234 h.summand_row = summand_row;
4235
4236 // A summand's variable set (`all_vars`) equals its flat tape's,
4237 // so this is `con_tape_colors` inverted to color-major order —
4238 // the loop `eval_h` actually runs.
4239 let mut by_color: Vec<Vec<u32>> = vec![Vec::new(); n_colors];
4240 for (si, s) in h.tape.summands.iter().enumerate() {
4241 let mut cs: Vec<u32> = s
4242 .all_vars
4243 .iter()
4244 .map(|&v| var_color[v])
4245 .filter(|&c| c != u32::MAX)
4246 .collect();
4247 cs.sort_unstable();
4248 cs.dedup();
4249 for c in cs {
4250 by_color[c as usize].push(si as u32);
4251 }
4252 }
4253 // Per-color prelude reach: the union of `prelude_reach` over the
4254 // color's summands, ascending. A union of operand-closed
4255 // ascending sets is itself operand-closed and ascending, which is
4256 // exactly what the two prelude sweeps require. Deduped with an
4257 // epoch-tagged buffer so the build costs
4258 // `Σ_c Σ_{s ∈ c} |prelude_reach_s|` — the same order as the work
4259 // it saves — rather than `n_colors × |prelude|`.
4260 let np = h.tape.n_prelude_ops();
4261 let mut seen: Vec<u32> = vec![0; np];
4262 let mut epoch: u32 = 0;
4263 let mut reach: Vec<u32> = Vec::new();
4264 let mut reach_off: Vec<usize> = Vec::with_capacity(n_colors + 1);
4265 for list in &by_color {
4266 reach_off.push(reach.len());
4267 epoch += 1;
4268 let start = reach.len();
4269 for &si in list {
4270 for &p in &h.tape.summands[si as usize].prelude_reach {
4271 if seen[p] != epoch {
4272 seen[p] = epoch;
4273 reach.push(p as u32);
4274 }
4275 }
4276 }
4277 reach[start..].sort_unstable();
4278 }
4279 reach_off.push(reach.len());
4280 h.hess_color_reach = reach;
4281 h.hess_color_reach_off = reach_off;
4282
4283 h.hess_color_summands = by_color;
4284 h.prelude_dot = vec![0.0; h.tape.n_prelude_ops()];
4285 h.hess_prelude_adj = vec![0.0; h.tape.n_prelude_ops()];
4286 h.prelude_adj_dot = vec![0.0; h.tape.n_prelude_ops()];
4287 h.local_dot = vec![0.0; h.tape.max_summand_ops()];
4288 h.local_adj_dot = vec![0.0; h.tape.max_summand_ops()];
4289 }
4290
4291 ColorTables {
4292 var_color,
4293 n_colors,
4294 peeled_cols: peeled
4295 .iter()
4296 .enumerate()
4297 .filter(|(_, p)| **p)
4298 .map(|(j, _)| j as u32)
4299 .collect(),
4300 seeds,
4301 decoding,
4302 obj_tape_colors,
4303 con_tape_colors,
4304 }
4305}
4306
4307/// The color-dependent half of an [`NlTnlp`], as built by
4308/// [`build_color_tables`].
4309struct ColorTables {
4310 var_color: Vec<u32>,
4311 n_colors: usize,
4312 /// Columns given a singleton color and dropped from the conflict
4313 /// structure. Small by construction (`MAX_PEELED_COLS`).
4314 peeled_cols: Vec<u32>,
4315 seeds: Vec<Vec<f64>>,
4316 decoding: Vec<Vec<ColorWrite>>,
4317 obj_tape_colors: Vec<Vec<u32>>,
4318 con_tape_colors: Vec<Vec<Vec<u32>>>,
4319}
4320
4321impl NlTnlp {
4322 /// Build the TNLP, panicking if AMPL external-function resolution fails.
4323 ///
4324 /// Kept for the many infallible call sites (CLI, tests) that operate on
4325 /// `.nl` models known to need no external libraries. Surfaces that can be
4326 /// handed an arbitrary user model — notably the Python `read_nl` binding —
4327 /// must call [`Self::try_new`] instead so a missing `$AMPLFUNC` library
4328 /// becomes a catchable error rather than an uncatchable panic across the
4329 /// pyo3 boundary.
4330 pub fn new(prob: NlProblem) -> Self {
4331 Self::try_new(prob)
4332 .unwrap_or_else(|e| panic!("failed to resolve AMPL external functions: {e}"))
4333 }
4334
4335 /// Build the TNLP, returning an error (instead of panicking) when AMPL
4336 /// imported functions named by the model can't be resolved — e.g.
4337 /// `$AMPLFUNC` is unset, a named library is missing/unloadable, or a
4338 /// referenced function id isn't registered by any loaded library.
4339 pub fn try_new(prob: NlProblem) -> Result<Self, String> {
4340 // `POUNCE_DBG_NO_QUAD=1` forces the AD tape for every row and the
4341 // objective — the A/B reference for the constant-structure path,
4342 // mirroring `POUNCE_DBG_NO_HYBRID`. Diagnostic only: it is how the
4343 // fast path's derivatives are checked against the ones they replace
4344 // on a real model, and how a suspected fast-path bug is bisected
4345 // against a reference that computes the same numbers a different way.
4346 Self::try_new_with_quadratic(prob, std::env::var("POUNCE_DBG_NO_QUAD").is_err())
4347 }
4348
4349 /// [`Self::try_new`] with the constant-structure fast path (gh #588, Q4)
4350 /// explicitly on or off.
4351 ///
4352 /// The env var `try_new` reads is process-global, which is exactly wrong
4353 /// for the differential test that has to build the *same* model both ways
4354 /// and compare the derivatives — so the knob is a parameter here and the
4355 /// env var only chooses its default.
4356 pub fn try_new_with_quadratic(prob: NlProblem, use_quadratic: bool) -> Result<Self, String> {
4357 // Resolve any AMPL imported (external) functions. Walk every
4358 // nonlinear expression to collect the funcall ids actually
4359 // referenced; load the libraries named in $AMPLFUNC and bind
4360 // each id to its (library, registered-name) pair so the tape
4361 // builder can emit live `TapeOp::Funcall` ops.
4362 let mut referenced: BTreeSet<usize> = BTreeSet::new();
4363 // Only trees can carry a `Funcall`: the recognizer refuses one, so
4364 // a recognized body provably contains none.
4365 for body in std::iter::once(&prob.obj_nonlinear).chain(prob.con_nonlinear.iter()) {
4366 if let Some(e) = body.tree() {
4367 super::nl_external::collect_funcall_ids(e, &mut referenced);
4368 }
4369 }
4370 let resolver = if referenced.is_empty() {
4371 super::nl_external::ExternalResolver::default()
4372 } else {
4373 super::nl_external::ExternalResolver::build_for_problem(
4374 &prob.imported_funcs,
4375 &referenced,
4376 )?
4377 };
4378
4379 // Recognize the degree-≤2 objective and rows *before* anything is
4380 // taped, because the win is not in evaluating the tape faster — it
4381 // is in never building it. On `qcqp500-3c` the ten quadratic rows
4382 // are 2.32 M monomials, one `Tape` each; recognizing them first
4383 // means those 2.32 M tapes, their color lists and their per-tape
4384 // sparsity sets are never allocated.
4385 //
4386 // A row that is trivially zero is left alone: it has no nonlinear
4387 // part to replace, and routing it through a (necessarily empty)
4388 // quadratic form would touch every model in the corpus to save
4389 // nothing.
4390 let mut quad = QuadraticStructure::new(prob.m);
4391 if use_quadratic {
4392 // `is_expanded_quadratic` is the accuracy gate, not an
4393 // optimization: it admits only forms whose read-out repeats the
4394 // additions the `.nl` writer already wrote. See its docs — and
4395 // note it is checked *before* recognition, so a factored form
4396 // costs one cheap structural walk rather than a full expansion.
4397 // Recognition is the parser's job now (gh #588, Q5) for the
4398 // bodies it could reach; `admitted_quad_form` reads its answer
4399 // back, and still walks a tree for the bodies that kept one —
4400 // a `from_expressions` model, a factored row rewound by the
4401 // parser, or any body at all under `POUNCE_DBG_NO_QUAD`.
4402 //
4403 // A form that gate refuses is offered the *factored* read-out
4404 // before it falls back to a tape (gh #673): the gate is a
4405 // verdict on the expansion, not on the body, and a sum of
4406 // squared residuals keeps its structure by keeping its squares.
4407 // `push_body_form` owns that order.
4408 if let Some(f) = push_body_form(&mut quad, &prob.obj_nonlinear) {
4409 quad.assign_objective(f);
4410 }
4411 for k in 0..prob.m {
4412 if let Some(f) = push_body_form(&mut quad, &prob.con_nonlinear[k]) {
4413 quad.assign_row(k, f);
4414 }
4415 }
4416 }
4417
4418 // Flatten objective and each constraint into independent
4419 // summands. Each summand becomes its own `Tape` (CSE bodies
4420 // are deduplicated within a tape via Rc identity in
4421 // `Tape::build`; bodies shared across summands are
4422 // duplicated, which we accept as a simplicity tradeoff).
4423 let obj_tapes: Vec<Tape> = if quad.objective_form().is_some() {
4424 Vec::new()
4425 } else {
4426 split_top_sums(&prob.obj_expr())
4427 .iter()
4428 .map(|e| Tape::build_with_externals(e, &resolver))
4429 .collect()
4430 };
4431
4432 let mut con_tapes: Vec<Vec<Tape>> = Vec::with_capacity(prob.m);
4433 let mut con_roots: Vec<Expr> = Vec::new();
4434 let mut row_start: Vec<usize> = Vec::with_capacity(prob.m + 1);
4435 for k in 0..prob.m {
4436 row_start.push(con_roots.len());
4437 // A row with a quadratic form contributes no tape and no
4438 // hybrid-tape root, so `con_tapes[k]` is empty and its
4439 // `row_start` range is empty too.
4440 if quad.row_form(k).is_some() {
4441 con_tapes.push(Vec::new());
4442 continue;
4443 }
4444 let summands = split_top_sums(&prob.con_expr(k));
4445 con_tapes.push(
4446 summands
4447 .iter()
4448 .map(|e| Tape::build_with_externals(e, &resolver))
4449 .collect(),
4450 );
4451 // Move (not clone) the split summands into the root list: their
4452 // `Expr::Cse` payloads are `Arc`s, and `build_multi` keys CSE
4453 // sharing on `Arc` pointer identity, so the roots must be the
4454 // same allocations the parse produced.
4455 con_roots.extend(summands);
4456 }
4457 row_start.push(con_roots.len());
4458
4459 // Shared-CSE constraint tape for `eval_g` (pounce#476). Worth
4460 // building only when some CSE body is actually referenced from two
4461 // or more summands — otherwise the prelude comes out empty and the
4462 // hybrid tape is the flat tape plus an indirection. `hybrid_supported`
4463 // gates the opcodes `build_multi` would panic on.
4464 // `POUNCE_DBG_NO_HYBRID=1` forces the flat per-summand tapes for the
4465 // whole constraint block. Diagnostic only: it is how the
4466 // flat-versus-shared trade in `HYBRID_JAC_MIN_OP_RATIO` is measured
4467 // on a real model, and how a suspected hybrid-path bug is bisected
4468 // against a reference that computes the same derivatives a
4469 // different way.
4470 let mut con_hybrid = if std::env::var("POUNCE_DBG_NO_HYBRID").is_ok() {
4471 None
4472 } else if hybrid_supported(&con_roots) {
4473 let tape = HybridTape::build_multi(&con_roots);
4474 (tape.n_prelude_ops() > 0).then(|| {
4475 let flat_ops: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
4476 let shared_ops = tape.n_prelude_ops() + tape.total_local_ops();
4477 // `POUNCE_DBG_FORCE_HYBRID_HESS=1` turns the Hessian gate on
4478 // regardless of the op ratio. Diagnostic only — it is how the
4479 // crossover in `HYBRID_HESS_MIN_OP_RATIO` is measured
4480 // (same-binary A/B against `POUNCE_DBG_NO_HYBRID=1`) on
4481 // models that sit below the gate.
4482 let force_hess = std::env::var("POUNCE_DBG_FORCE_HYBRID_HESS").is_ok();
4483 ConHybrid {
4484 prelude_vals: vec![0.0; tape.n_prelude_ops()],
4485 local_vals: vec![0.0; tape.max_summand_ops()],
4486 local_adj: vec![0.0; tape.max_summand_ops()],
4487 prelude_adj: vec![0.0; tape.n_prelude_ops()],
4488 use_for_jac: flat_ops as f64
4489 >= HYBRID_JAC_MIN_OP_RATIO * shared_ops.max(1) as f64,
4490 use_for_hess: force_hess
4491 || flat_ops as f64 >= HYBRID_HESS_MIN_OP_RATIO * shared_ops.max(1) as f64,
4492 local_vals_all: Vec::new(),
4493 local_off: Vec::new(),
4494 summand_row: Vec::new(),
4495 hess_color_summands: Vec::new(),
4496 hess_color_reach: Vec::new(),
4497 hess_color_reach_off: Vec::new(),
4498 prelude_dot: Vec::new(),
4499 hess_prelude_adj: Vec::new(),
4500 prelude_adj_dot: Vec::new(),
4501 local_dot: Vec::new(),
4502 local_adj_dot: Vec::new(),
4503 row_start,
4504 tape,
4505 }
4506 })
4507 } else {
4508 None
4509 };
4510 drop(con_roots);
4511
4512 // Hessian-of-Lagrangian sparsity: union of each tape's own
4513 // structural Hessian sparsity.
4514 // One flat `Vec`, sorted and deduped once, rather than a global
4515 // `BTreeSet` fed a single insert at a time across every summand
4516 // in the model: sort+dedup walks contiguous memory where the tree
4517 // chased a pointer and allocated a node per entry. The result is
4518 // exactly the ascending order the rest of this function wants, so
4519 // it doubles as `lower_pairs` instead of being copied into it.
4520 let mut tape_pairs: Vec<(usize, usize)> = Vec::new();
4521 for t in &obj_tapes {
4522 tape_pairs.extend(t.hessian_sparsity());
4523 }
4524 for row in &con_tapes {
4525 for t in row {
4526 tape_pairs.extend(t.hessian_sparsity());
4527 }
4528 }
4529 tape_pairs.sort_unstable();
4530 tape_pairs.dedup();
4531
4532 // The assembled pattern is the tapes' union *plus* the quadratic
4533 // forms'. They are kept apart because the coloring only ever needs
4534 // the tape half — a quadratic block's entries are scattered
4535 // directly, not recovered from a directional product — and coloring
4536 // a dense quadratic block would put the color count back at `n` to
4537 // pay for products nobody runs (`qcqp500-3c`: 500 colors → 0).
4538 let mut lower_pairs = tape_pairs.clone();
4539 if !quad.is_empty() {
4540 for f in quad
4541 .objective_form()
4542 .into_iter()
4543 .chain((0..prob.m).filter_map(|i| quad.row_form(i)))
4544 {
4545 lower_pairs.extend(
4546 quad.lower_triangle(f)
4547 .map(|(r, c, _)| (r as usize, c as usize)),
4548 );
4549 }
4550 lower_pairs.sort_unstable();
4551 lower_pairs.dedup();
4552 }
4553
4554 // Which assembled entries a tape can write. Both sides are sorted
4555 // and `tape_pairs ⊆ lower_pairs`, so this is a merge walk, and the
4556 // mask is left empty (meaning "all of them") when nothing was
4557 // recognized.
4558 let h_tape_mask: Vec<bool> = if quad.is_empty() {
4559 Vec::new()
4560 } else {
4561 let mut mask = vec![false; lower_pairs.len()];
4562 let mut t = 0usize;
4563 for (idx, pair) in lower_pairs.iter().enumerate() {
4564 if t < tape_pairs.len() && tape_pairs[t] == *pair {
4565 mask[idx] = true;
4566 t += 1;
4567 }
4568 }
4569 debug_assert_eq!(t, tape_pairs.len(), "every tape pair is in the union");
4570 mask
4571 };
4572 drop(tape_pairs);
4573
4574 let mut h_irow = Vec::with_capacity(lower_pairs.len());
4575 let mut h_jcol = Vec::with_capacity(lower_pairs.len());
4576 for &(hi, lo) in &lower_pairs {
4577 h_irow.push(hi as i32);
4578 h_jcol.push(lo as i32);
4579 }
4580
4581 // Bind each form's lower-triangle entries to their index in the
4582 // assembled pattern. `lower_pairs` is sorted, so the lookup is a
4583 // binary search — done once here, never again on the hot path.
4584 if !quad.is_empty() {
4585 quad.bind_slots(|r, c| {
4586 lower_pairs
4587 .binary_search(&(r as usize, c as usize))
4588 .unwrap_or_else(|_| {
4589 unreachable!("quadratic entry ({r}, {c}) missing from the union pattern")
4590 })
4591 });
4592 }
4593
4594 // Hessian column coloring and everything keyed off it. The
4595 // chromatic number of the column-intersection graph bounds how
4596 // many directional Hessian-vector products we need per `eval_h`
4597 // call — typically O(stencil) for PDE-mesh problems.
4598 let ColorTables {
4599 var_color,
4600 n_colors,
4601 peeled_cols,
4602 seeds,
4603 decoding,
4604 obj_tape_colors,
4605 con_tape_colors,
4606 } = build_color_tables(
4607 prob.n,
4608 prob.m,
4609 &lower_pairs,
4610 &h_tape_mask,
4611 &vec![false; prob.n],
4612 &obj_tapes,
4613 &con_tapes,
4614 con_hybrid.as_mut(),
4615 );
4616
4617 // Per-row Jacobian sparsity = union of tape vars plus
4618 // linear-segment vars.
4619 let mut jac_cols: Vec<Vec<usize>> = Vec::with_capacity(prob.m);
4620 let mut jac_nnz = 0;
4621 for (i, row_tapes) in con_tapes.iter().enumerate() {
4622 let mut cols: Vec<usize> = Vec::with_capacity(prob.con_linear[i].len());
4623 for t in row_tapes {
4624 cols.extend(t.variables());
4625 }
4626 // A quadratic row has no tape, so its Jacobian support comes
4627 // from the form: `Hx + a` is nonzero exactly on the union of the
4628 // Hessian's rows and the folded linear part.
4629 if let Some(f) = quad.row_form(i) {
4630 cols.extend(quad.gradient_support(f).iter().map(|&v| v as usize));
4631 }
4632 cols.extend(prob.con_linear[i].iter().map(|(v, _)| *v));
4633 cols.sort_unstable();
4634 cols.dedup();
4635 cols.shrink_to_fit();
4636 jac_nnz += cols.len();
4637 jac_cols.push(cols);
4638 }
4639
4640 let mut max_tape_n: usize = 0;
4641 for t in &obj_tapes {
4642 max_tape_n = max_tape_n.max(t.ops.len());
4643 }
4644 for row in &con_tapes {
4645 for t in row {
4646 max_tape_n = max_tape_n.max(t.ops.len());
4647 }
4648 }
4649
4650 if std::env::var("POUNCE_DBG_TAPE_STATS").is_ok() {
4651 let n_obj = obj_tapes.len();
4652 let n_con: usize = con_tapes.iter().map(|r| r.len()).sum();
4653 let total = n_obj + n_con;
4654 let mut sum_ops: usize = 0;
4655 for t in &obj_tapes {
4656 sum_ops += t.ops.len();
4657 }
4658 for row in &con_tapes {
4659 for t in row {
4660 sum_ops += t.ops.len();
4661 }
4662 }
4663 let t = total.max(1);
4664 let nnz_h = h_irow.len();
4665 let avg_decode =
4666 decoding.iter().map(|d| d.len()).sum::<usize>() as f64 / n_colors.max(1) as f64;
4667 eprintln!(
4668 "[tape stats] summands={total} (obj={n_obj} con={n_con}) \
4669 total_ops={sum_ops} avg_ops={:.1} max_ops={max_tape_n} \
4670 n_colors={n_colors} avg_decode_per_color={avg_decode:.1} nnz_h={nnz_h}",
4671 sum_ops as f64 / t as f64,
4672 );
4673 // Flat vs shared-CSE op counts for the constraint block. The
4674 // ratio is how much duplicated CSE work `eval_g`'s hybrid path
4675 // avoids, and the ceiling on what routing the Jacobian /
4676 // Hessian through the same prelude could save.
4677 match &con_hybrid {
4678 Some(h) => {
4679 let flat: usize = con_tapes.iter().flatten().map(|t| t.ops.len()).sum();
4680 let prelude = h.tape.n_prelude_ops();
4681 let local = h.tape.total_local_ops();
4682 eprintln!(
4683 "[hybrid stats] con flat_ops={flat} prelude_ops={prelude} \
4684 local_ops={local} shared_total={} flat/shared={:.2}x \
4685 jac_gate={} hess_gate={}",
4686 prelude + local,
4687 flat as f64 / (prelude + local).max(1) as f64,
4688 if h.use_for_jac { "on" } else { "off" },
4689 if h.use_for_hess { "on" } else { "off" },
4690 );
4691 }
4692 None => eprintln!("[hybrid stats] con hybrid not built (no shared CSE bodies)"),
4693 }
4694 // What the constant-structure path took off the tape builder.
4695 // `forms` counts the objective too, which is why it can exceed
4696 // the row count by one.
4697 let quad_rows = (0..prob.m).filter(|&i| quad.row_form(i).is_some()).count();
4698 // How many of those forms the *parser* produced, i.e. how many
4699 // never cost an `Expr` (gh #588, Q5). The rest were recognized
4700 // from a tree that was built: a `from_expressions` model, or a
4701 // body the parser rewound because it was not degree 2.
4702 let parsed = std::iter::once(&prob.obj_nonlinear)
4703 .chain(prob.con_nonlinear.iter())
4704 .filter(|b| b.quad().is_some())
4705 .count();
4706 eprintln!(
4707 "[quad stats] forms={} (parse-time {parsed}) rows={quad_rows}/{} obj={} \
4708 stored_h_entries={} colored_pairs={}/{}",
4709 quad.len(),
4710 prob.m,
4711 if quad.objective_form().is_some() {
4712 "quadratic"
4713 } else {
4714 "taped"
4715 },
4716 quad.stored_entries(),
4717 if h_tape_mask.is_empty() {
4718 lower_pairs.len()
4719 } else {
4720 h_tape_mask.iter().filter(|&&t| t).count()
4721 },
4722 lower_pairs.len(),
4723 );
4724 }
4725
4726 let compressed: Vec<Vec<f64>> = vec![vec![0.0; prob.n]; n_colors];
4727
4728 let mut me = Self {
4729 prob,
4730 obj_tapes,
4731 con_tapes,
4732 con_hybrid,
4733 quad,
4734 h_tape_mask,
4735 h_irow,
4736 h_jcol,
4737 jac_cols,
4738 jac_nnz,
4739 seeds,
4740 decoding,
4741 obj_tape_colors,
4742 con_tape_colors,
4743 var_color,
4744 peeled_cols,
4745 final_x: None,
4746 final_obj: 0.0,
4747 final_lambda: None,
4748 final_z_l: None,
4749 final_z_u: None,
4750 scratch_row_grad: Vec::new(),
4751 vals_scratch: vec![0.0; max_tape_n],
4752 dot_scratch: vec![0.0; max_tape_n],
4753 adj_scratch: vec![0.0; max_tape_n],
4754 adj_dot_scratch: vec![0.0; max_tape_n],
4755 compressed,
4756 hvp_live: Vec::new(),
4757 curvature_scaling: None,
4758 };
4759 me.veto_ill_conditioned_peels();
4760 Ok(me)
4761 }
4762
4763 /// Un-peel any dense column whose own pass is too ill-scaled to read
4764 /// its small entries out of, and re-color if that changes anything.
4765 ///
4766 /// `greedy_hessian_coloring` picks the peel set from structure alone,
4767 /// which is the right call for the memory and the color count but
4768 /// blind to the one thing that can go wrong: an entry recovered from
4769 /// column `d`'s pass inherits that pass's roundoff floor, about
4770 /// `eps * ||H(:, d)||`, rather than its own much smaller one. A
4771 /// well-scaled dense column loses nothing to that — the recovered
4772 /// entries come back bit-identical to the uncompressed reference on
4773 /// every peel-firing model in the benchmark corpus but one. A column
4774 /// spanning many orders of magnitude, though, hands its small entries
4775 /// a relative error of `eps * ||H(:, d)|| / |H[d, j]|`, which on
4776 /// `cho_parmest` (a 12-parameter kinetic fit whose peeled columns
4777 /// hold both 2.8e5 and 5.6e-4) reaches 1e-5. The primal solution
4778 /// survives that, but the multipliers come out of the KKT system the
4779 /// Hessian sits in, so `inf_du` picks up a jitter floor near 1e-6 and
4780 /// the solve stalls short of `Optimal` on a problem it used to
4781 /// certify.
4782 ///
4783 /// Nothing structural distinguishes the two cases, so measure it: one
4784 /// Hessian evaluation at `x0` with unit multipliers leaves each peeled
4785 /// column's exact pass sitting in `compressed`, and a column whose
4786 /// worst recovered entry would lose more than
4787 /// `PEEL_MAX_REL_ERR` is vetoed and colored the ordinary way. Costs
4788 /// one `eval_h` — at the peeled color count, so cheap — and only for
4789 /// the ~3% of models that peel anything at all.
4790 fn veto_ill_conditioned_peels(&mut self) {
4791 if self.peeled_cols.is_empty() {
4792 return;
4793 }
4794
4795 let mut values = vec![0.0; self.h_irow.len()];
4796 let lambda = vec![1.0; self.prob.m];
4797 let x0 = self.prob.x0.clone();
4798 if !self.eval_h(
4799 Some(&x0),
4800 true,
4801 1.0,
4802 Some(&lambda),
4803 true,
4804 SparsityRequest::Values {
4805 values: &mut values,
4806 },
4807 ) {
4808 return;
4809 }
4810
4811 let dbg = std::env::var("POUNCE_DBG_TAPE_STATS").is_ok();
4812 // A column whose whole pass is negligible against the Hessian as a
4813 // whole cannot move the KKT system no matter how badly its own
4814 // entries are rounded, and columns that are identically zero at
4815 // `x0` would otherwise veto on a ratio of pure noise.
4816 let h_scale = self
4817 .compressed
4818 .iter()
4819 .flat_map(|c| c.iter())
4820 .fold(0.0f64, |a, &v| a.max(v.abs()));
4821 let mut peel_veto = vec![false; self.prob.n];
4822 let mut vetoed = 0usize;
4823 for &d in &self.peeled_cols {
4824 let c = self.var_color[d as usize];
4825 if c == u32::MAX {
4826 continue;
4827 }
4828 let pass = &self.compressed[c as usize];
4829 // The floor the pass was accumulated at, against the smallest
4830 // entry actually read out of it.
4831 let scale = pass.iter().fold(0.0f64, |a, &v| a.max(v.abs()));
4832 // Entries at or below the pass's own roundoff floor carry no
4833 // information to lose: `eps * scale` is the noise the pass was
4834 // accumulated at, so such an entry is already indistinguishable
4835 // from zero whether or not the column is peeled. Including them
4836 // would divide by that noise -- `orthregd` holds entries of
4837 // 8e-15 in a pass of norm 6e5, and bounds at 1e4 while measuring
4838 // 4e-16 against an uncompressed reference.
4839 let noise = f64::EPSILON * scale;
4840 let smallest = self.decoding[c as usize]
4841 .iter()
4842 .map(|w| pass[w.row as usize].abs())
4843 .filter(|v| *v > noise)
4844 .fold(f64::INFINITY, f64::min);
4845 if !smallest.is_finite() || smallest == 0.0 || scale == 0.0 {
4846 continue;
4847 }
4848 if scale <= h_scale * f64::EPSILON {
4849 continue;
4850 }
4851 let rel_err = f64::EPSILON * scale / smallest;
4852 if dbg {
4853 eprintln!(
4854 "[peel probe] col={d} color={c} ||pass||={scale:.3e} \
4855 min_entry={smallest:.3e} rel_err={rel_err:.3e}{}",
4856 if rel_err > PEEL_MAX_REL_ERR {
4857 " VETO"
4858 } else {
4859 ""
4860 }
4861 );
4862 }
4863 if rel_err > PEEL_MAX_REL_ERR {
4864 peel_veto[d as usize] = true;
4865 vetoed += 1;
4866 }
4867 }
4868
4869 if vetoed == 0 {
4870 return;
4871 }
4872 if dbg {
4873 eprintln!(
4874 "[peel probe] vetoing {vetoed}/{} peeled columns; re-coloring",
4875 self.peeled_cols.len()
4876 );
4877 }
4878 self.recolor(&peel_veto);
4879 }
4880
4881 /// Rebuild the coloring and everything keyed off it, barring
4882 /// `peel_veto` from the peel set.
4883 fn recolor(&mut self, peel_veto: &[bool]) {
4884 let lower_pairs: Vec<(usize, usize)> = self
4885 .h_irow
4886 .iter()
4887 .zip(&self.h_jcol)
4888 .map(|(&i, &j)| (i as usize, j as usize))
4889 .collect();
4890 let ColorTables {
4891 var_color,
4892 n_colors,
4893 peeled_cols,
4894 seeds,
4895 decoding,
4896 obj_tape_colors,
4897 con_tape_colors,
4898 } = build_color_tables(
4899 self.prob.n,
4900 self.prob.m,
4901 &lower_pairs,
4902 &self.h_tape_mask,
4903 peel_veto,
4904 &self.obj_tapes,
4905 &self.con_tapes,
4906 self.con_hybrid.as_mut(),
4907 );
4908 self.var_color = var_color;
4909 self.peeled_cols = peeled_cols;
4910 self.seeds = seeds;
4911 self.decoding = decoding;
4912 self.obj_tape_colors = obj_tape_colors;
4913 self.con_tape_colors = con_tape_colors;
4914 self.compressed = vec![vec![0.0; self.prob.n]; n_colors];
4915 }
4916
4917 pub fn final_x(&self) -> Option<&[Number]> {
4918 self.final_x.as_deref()
4919 }
4920
4921 pub fn final_obj(&self) -> Number {
4922 self.final_obj
4923 }
4924
4925 /// Converged constraint multipliers from the last solve, in original
4926 /// `.nl` row order. `None` before a solve finishes. See
4927 /// [`Self::final_x`] for the primal counterpart.
4928 pub fn final_lambda(&self) -> Option<&[Number]> {
4929 self.final_lambda.as_deref()
4930 }
4931
4932 /// Converged lower / upper bound multipliers from the last solve, in
4933 /// original `.nl` variable order and Ipopt's internal convention (both
4934 /// `>= 0`). `None` before a solve finishes.
4935 pub fn final_bound_multipliers(&self) -> Option<(&[Number], &[Number])> {
4936 Some((self.final_z_l.as_deref()?, self.final_z_u.as_deref()?))
4937 }
4938
4939 /// The parsed problem this TNLP evaluates (bounds, starting point,
4940 /// names, suffixes). Read-only; per-instance overrides go through
4941 /// [`Self::variant`].
4942 pub fn problem(&self) -> &NlProblem {
4943 &self.prob
4944 }
4945
4946 /// Opt this model in to **curvature-based** scaling (gh #703): compute
4947 /// the per-variable and per-row factors of
4948 /// [`crate::nl_scaling::curvature_scaling`] and serve them from
4949 /// [`TNLP::get_scaling_parameters`], so `nlp_scaling_method` reaches
4950 /// them through the channel it already has for user factors.
4951 ///
4952 /// Returns `false` when the model is not one the scheme is defined for
4953 /// — some row or the objective is not degree ≤ 2, so no constant `Qᵢ`
4954 /// exists. The caller must surface that as an error rather than solving
4955 /// unscaled: an accepted scaling option that is then quietly not applied
4956 /// is exactly the gh #483 failure.
4957 ///
4958 /// Costs one pass over every stored Hessian entry plus `RUIZ_SWEEPS`
4959 /// passes over the magnitude surrogates, and nothing at all for a solve
4960 /// that never calls it.
4961 pub fn enable_curvature_scaling(&mut self) -> bool {
4962 match crate::nl_scaling::curvature_scaling(&self.prob) {
4963 Some(sc) => {
4964 self.curvature_scaling = Some(sc);
4965 true
4966 }
4967 None => false,
4968 }
4969 }
4970
4971 /// Whether [`Self::enable_curvature_scaling`] has been called and
4972 /// succeeded.
4973 pub fn curvature_scaling_enabled(&self) -> bool {
4974 self.curvature_scaling.is_some()
4975 }
4976
4977 /// Whether the enabled curvature scaling actually read any curvature —
4978 /// see [`crate::nl_scaling::CurvatureScaling::quadratic`]. `false` when
4979 /// scaling is not enabled, and `false` for a degree-≤2 model whose every
4980 /// `Q` is empty (an LP), where the scheme degenerates to plain Ruiz
4981 /// equilibration of `[A b]`.
4982 pub fn curvature_scaling_read_curvature(&self) -> bool {
4983 self.curvature_scaling
4984 .as_ref()
4985 .is_some_and(|sc| sc.quadratic)
4986 }
4987
4988 /// Is constraint row `i` evaluated from a constant quadratic form
4989 /// rather than from an AD tape (gh #588, Q4)?
4990 ///
4991 /// Structural, so it answers before any evaluation. Exposed for the
4992 /// differential test, which has to know which models exercise the fast
4993 /// path at all, and for `POUNCE_DBG_TAPE_STATS`.
4994 pub fn quadratic_row(&self, i: usize) -> bool {
4995 self.quad.row_form(i).is_some()
4996 }
4997
4998 /// As [`Self::quadratic_row`], for the objective.
4999 pub fn quadratic_objective(&self) -> bool {
5000 self.quad.objective_form().is_some()
5001 }
5002
5003 /// Structural set of variables that appear in *some* nonlinear part —
5004 /// the union of `collect_vars` over the objective and every constraint
5005 /// row. Shared by `get_variables_linearity` and the nonlinear-variable
5006 /// list below so the two can never disagree.
5007 fn nonlinear_var_set(&self) -> BTreeSet<usize> {
5008 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
5009 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
5010 for row in &self.prob.con_nonlinear {
5011 row.collect_vars(&mut nonlinear);
5012 }
5013 nonlinear
5014 }
5015
5016 /// The nonlinear-variable list published through the TNLP contract
5017 /// (`get_number_of_nonlinear_variables` / `get_list_of_nonlinear_variables`),
5018 /// ascending, in the C index style [`NlTnlp`] reports.
5019 ///
5020 /// The contract's asymmetry decides how this is computed. A consumer
5021 /// treats every variable *absent* from the list as linear — Ipopt's
5022 /// limited-memory Hessian skips the quasi-Newton update in that
5023 /// subspace — so naming too few variables is a wrong answer, while
5024 /// naming too many merely costs work. Hence:
5025 ///
5026 /// * When the `.nl` header says **every** variable is nonlinear, publish
5027 /// that and skip the walk. This is the maximally conservative answer,
5028 /// so it is sound whatever the header's provenance, and it is the
5029 /// common case for the models this matters on (`eigena2`: 110 of 110).
5030 /// * Otherwise walk the trees. The header's prefix
5031 /// (`nlvc + nlvo − nlvb` — see [`NlCounts`]) would also be an O(1)
5032 /// answer, but it would be one that *trusts* the writer to have
5033 /// ordered the variables as the format requires, and the walk is
5034 /// already paid once per solve by `get_variables_linearity`. The
5035 /// walk is also the only option for a model built through
5036 /// [`NlProblem::from_expressions`], which has no header at all.
5037 ///
5038 /// The two disagree only in the safe direction, which
5039 /// `crates/pounce-cli/tests/nl_header_counts.rs` asserts over the
5040 /// fixture corpus: the walked set always sits inside the header's
5041 /// prefix, because `parse_nl_text` folds constant `C` bodies away
5042 /// (`gh #492`) and AMPL's own census predates that fold.
5043 fn nonlinear_variables(&self) -> Vec<Index> {
5044 if let Some(c) = self.prob.nl_counts
5045 && c.nonlinear_vars() >= self.prob.n
5046 {
5047 return (0..self.prob.n as Index).collect();
5048 }
5049 self.nonlinear_var_set()
5050 .into_iter()
5051 .map(|i| i as Index)
5052 .collect()
5053 }
5054
5055 /// Mutable access to that same problem, for a caller that owns this
5056 /// TNLP outright.
5057 ///
5058 /// The tapes were built from the expressions in [`Self::problem`] and
5059 /// are not rebuilt, so editing an expression here does **not** change
5060 /// what this TNLP evaluates. It exists for teardown: the Python
5061 /// binding takes the expression trees out through here so a deeply
5062 /// nested one is dropped on a stack chosen for it rather than
5063 /// recursively on whatever thread collected the object (pounce#472).
5064 pub fn problem_mut(&mut self) -> &mut NlProblem {
5065 &mut self.prob
5066 }
5067
5068 /// Hessian-vector product of the Lagrangian:
5069 /// `out = (obj_factor·∇²f(x) + Σ_i λ_i·∇²g_i(x)) · v`.
5070 ///
5071 /// This is the matrix-free counterpart of `eval_h`. `eval_h` runs one
5072 /// [`Tape::hessian_directional`] pass *per color* and then decodes the
5073 /// compressed columns into the sparse lower triangle; here the seed is
5074 /// the caller's `v` directly, so it is a single forward-over-reverse
5075 /// pass per tape — O(tape ops), independent of `n` and of the coloring's
5076 /// chromatic number. That is what makes it usable on models where
5077 /// materializing `∇²L` is impractical (issue #469): a Newton–Krylov /
5078 /// truncated-CG step only ever needs `∇²L · v`.
5079 ///
5080 /// Sign convention matches `eval_h` and the rest of this evaluator: a
5081 /// `maximize` model's objective is negated so the returned operator is
5082 /// the one that minimizing solves. `lambda` is `None` for the objective
5083 /// block alone.
5084 ///
5085 /// `out` is overwritten (not accumulated into). Errors on any length
5086 /// mismatch rather than panicking, since the Python binding hands this
5087 /// arbitrary user arrays.
5088 pub fn hessian_vector_product(
5089 &mut self,
5090 x: &[Number],
5091 v: &[Number],
5092 obj_factor: Number,
5093 lambda: Option<&[Number]>,
5094 out: &mut [Number],
5095 ) -> Result<(), String> {
5096 self.hessian_vector_products(x, v, 1, obj_factor, lambda, out)
5097 }
5098
5099 /// Block form of [`Self::hessian_vector_product`]: `k` directions at
5100 /// once, `out[:, c] = ∇²L · v[:, c]`.
5101 ///
5102 /// `v` and `out` are `n × k` in **column-major** order — direction `c`
5103 /// occupies `v[c*n .. (c+1)*n]`. `out` is overwritten.
5104 ///
5105 /// Worth having as its own entry point rather than a loop over the
5106 /// single-vector call: the forward sweep depends only on `x`, so a block
5107 /// runs it *once per tape* and reuses `vals` across all `k` directions,
5108 /// where `k` separate calls would redo it `k` times. Only the
5109 /// forward-tangent + reverse-over-tangent passes are per-direction. That
5110 /// is the shape a block-Krylov solve, a directional-derivative probe, or
5111 /// a densify-the-Hessian loop wants.
5112 ///
5113 /// An all-zero direction is skipped, so passing a sparse block whose
5114 /// columns are mostly empty costs only the columns that carry signal.
5115 /// (The sparsity that dominates is the model's own: each tape touches
5116 /// only its own variables, and `hessian_directional` is O(tape ops), not
5117 /// O(n).)
5118 pub fn hessian_vector_products(
5119 &mut self,
5120 x: &[Number],
5121 v: &[Number],
5122 k: usize,
5123 obj_factor: Number,
5124 lambda: Option<&[Number]>,
5125 out: &mut [Number],
5126 ) -> Result<(), String> {
5127 let (n, m) = (self.prob.n, self.prob.m);
5128 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5129 if got == want {
5130 Ok(())
5131 } else {
5132 Err(format!(
5133 "hessian_vector_product: {name} has length {got}, expected {want}"
5134 ))
5135 }
5136 };
5137 check("x", x.len(), n)?;
5138 check("v", v.len(), n * k)?;
5139 check("out", out.len(), n * k)?;
5140 if let Some(lam) = lambda {
5141 check("lambda", lam.len(), m)?;
5142 }
5143
5144 out.fill(0.0);
5145 if k == 0 || n == 0 {
5146 return Ok(());
5147 }
5148
5149 // Which directions carry signal. Computed once, not per (tape,
5150 // direction) pair — with many small summand tapes the scan would
5151 // otherwise dominate the work it is meant to save. Reuses the
5152 // persistent mask so a Krylov loop allocates nothing per iteration.
5153 self.hvp_live.clear();
5154 self.hvp_live
5155 .extend((0..k).map(|c| v[c * n..(c + 1) * n].iter().any(|&s| s != 0.0)));
5156 if !self.hvp_live.iter().any(|&l| l) {
5157 return Ok(());
5158 }
5159
5160 let obj_seed = if self.prob.minimize {
5161 obj_factor
5162 } else {
5163 -obj_factor
5164 };
5165 // The constant blocks. `H · v` for a quadratic form is a matvec
5166 // against stored values, so unlike a tape it needs no forward sweep
5167 // and no `x` at all. Easy to forget — a matrix-free solve that
5168 // silently dropped the quadratic part of `∇²L` would still converge,
5169 // just to a different point by a different route, which is the
5170 // failure mode this series is most exposed to.
5171 if !self.quad.is_empty() {
5172 if obj_seed != 0.0 {
5173 if let Some(f) = self.quad.objective_form() {
5174 for (c, out_col) in out.chunks_mut(n).enumerate() {
5175 if self.hvp_live[c] {
5176 self.quad.add_hessian_vector(
5177 f,
5178 &v[c * n..(c + 1) * n],
5179 obj_seed,
5180 out_col,
5181 );
5182 }
5183 }
5184 }
5185 }
5186 if let Some(lam) = lambda {
5187 for (i, &w) in lam.iter().enumerate() {
5188 if w == 0.0 {
5189 continue;
5190 }
5191 let Some(f) = self.quad.row_form(i) else {
5192 continue;
5193 };
5194 for (c, out_col) in out.chunks_mut(n).enumerate() {
5195 if self.hvp_live[c] {
5196 self.quad
5197 .add_hessian_vector(f, &v[c * n..(c + 1) * n], w, out_col);
5198 }
5199 }
5200 }
5201 }
5202 }
5203
5204 if obj_seed != 0.0 {
5205 for t in &self.obj_tapes {
5206 if t.ops.is_empty() {
5207 continue;
5208 }
5209 // Once per tape, not once per direction — the whole point
5210 // of the block form.
5211 t.forward_into(x, &mut self.vals_scratch);
5212 for (c, out_col) in out.chunks_mut(n).enumerate() {
5213 if !self.hvp_live[c] {
5214 continue;
5215 }
5216 t.hessian_directional(
5217 &self.vals_scratch,
5218 &v[c * n..(c + 1) * n],
5219 obj_seed,
5220 out_col,
5221 &mut self.dot_scratch,
5222 &mut self.adj_scratch,
5223 &mut self.adj_dot_scratch,
5224 );
5225 }
5226 }
5227 }
5228
5229 if let Some(lam) = lambda {
5230 // `lam.len() == m` was checked above, so `con_tapes[k]` is in
5231 // range for every k.
5232 for (i, &w) in lam.iter().enumerate() {
5233 if w == 0.0 {
5234 continue;
5235 }
5236 for t in &self.con_tapes[i] {
5237 if t.ops.is_empty() {
5238 continue;
5239 }
5240 t.forward_into(x, &mut self.vals_scratch);
5241 for (c, out_col) in out.chunks_mut(n).enumerate() {
5242 if !self.hvp_live[c] {
5243 continue;
5244 }
5245 t.hessian_directional(
5246 &self.vals_scratch,
5247 &v[c * n..(c + 1) * n],
5248 w,
5249 out_col,
5250 &mut self.dot_scratch,
5251 &mut self.adj_scratch,
5252 &mut self.adj_dot_scratch,
5253 );
5254 }
5255 }
5256 }
5257 }
5258
5259 Ok(())
5260 }
5261
5262 /// Clone this TNLP with per-instance overrides applied — the
5263 /// "one structure, many bound / starting-point variations" case of
5264 /// batched NLP solving (pounce#126). The AD tapes, sparsity, and
5265 /// coloring are reused via `Clone` (they depend only on the model
5266 /// structure, which a variation cannot change); only the values in
5267 /// `prob.x0` / `prob.x_l` / `prob.x_u` / `prob.g_l` / `prob.g_u`
5268 /// are replaced. Any stale `final_x` from a previous solve of
5269 /// `self` is cleared on the clone.
5270 ///
5271 /// Errors when an override's length does not match the model
5272 /// (`n` for `x0`/`x_l`/`x_u`, `m` for `g_l`/`g_u`).
5273 pub fn variant(&self, v: &NlVariation) -> Result<Self, String> {
5274 let check = |name: &str, got: usize, want: usize| -> Result<(), String> {
5275 if got == want {
5276 Ok(())
5277 } else {
5278 Err(format!(
5279 "NlVariation.{name} has length {got}, expected {want}"
5280 ))
5281 }
5282 };
5283 let mut out = self.clone();
5284 out.final_x = None;
5285 out.final_obj = 0.0;
5286 out.final_lambda = None;
5287 out.final_z_l = None;
5288 out.final_z_u = None;
5289 if let Some(x0) = &v.x0 {
5290 check("x0", x0.len(), self.prob.n)?;
5291 out.prob.x0.clone_from(x0);
5292 }
5293 if let Some(x_l) = &v.x_l {
5294 check("x_l", x_l.len(), self.prob.n)?;
5295 out.prob.x_l.clone_from(x_l);
5296 }
5297 if let Some(x_u) = &v.x_u {
5298 check("x_u", x_u.len(), self.prob.n)?;
5299 out.prob.x_u.clone_from(x_u);
5300 }
5301 if let Some(g_l) = &v.g_l {
5302 check("g_l", g_l.len(), self.prob.m)?;
5303 out.prob.g_l.clone_from(g_l);
5304 }
5305 if let Some(g_u) = &v.g_u {
5306 check("g_u", g_u.len(), self.prob.m)?;
5307 out.prob.g_u.clone_from(g_u);
5308 }
5309 Ok(out)
5310 }
5311
5312 /// Build one [`NlTnlp`] per variation, sharing this instance's
5313 /// structure (see [`Self::variant`]). Returns instances in input
5314 /// order; errors on the first length-mismatched variation.
5315 pub fn variants(&self, vs: &[NlVariation]) -> Result<Vec<Self>, String> {
5316 vs.iter().map(|v| self.variant(v)).collect()
5317 }
5318}
5319
5320/// Per-instance overrides for building a family of related NLP
5321/// instances from one parsed `.nl` model (pounce#126): same structure
5322/// and tapes, different starting point and/or bounds — parametric
5323/// sweeps, multi-start, or branch-and-bound node relaxations where
5324/// each node only tightens variable bounds. `None` keeps the base
5325/// model's value.
5326#[derive(Debug, Clone, Default)]
5327pub struct NlVariation {
5328 pub x0: Option<Vec<Number>>,
5329 pub x_l: Option<Vec<Number>>,
5330 pub x_u: Option<Vec<Number>>,
5331 pub g_l: Option<Vec<Number>>,
5332 pub g_u: Option<Vec<Number>>,
5333}
5334
5335impl pounce_nlp::expression_provider::ExpressionProvider for NlTnlp {
5336 /// Per-`.nl`-row constraint expression tape, with the linear
5337 /// part folded in. Returns `None` for constraints that contribute
5338 /// neither a nonlinear expression nor any linear coefficients
5339 /// (so FBBT skips them — there's nothing to tighten).
5340 fn constraint_expression(&self, i: usize) -> Option<pounce_nlp::FbbtTape> {
5341 if i >= self.prob.con_nonlinear.len() {
5342 return None;
5343 }
5344 let nonlinear = self.prob.con_expr(i);
5345 let linear = self
5346 .prob
5347 .con_linear
5348 .get(i)
5349 .map(|v| v.as_slice())
5350 .unwrap_or(&[]);
5351 // FBBT needs the tree, so a recognized body is rebuilt here. It is
5352 // rebuilt once per row per presolve pass and dropped again, so the
5353 // DAG never all exists at once — which is the property the phase is
5354 // actually about. Translating the stored coefficients instead would
5355 // propagate bounds through a different association and change the
5356 // tightening; that is not a trade this phase makes.
5357 crate::nl_fbbt_translate::translate_constraint(&nonlinear, linear)
5358 }
5359
5360 /// Variable name from the sibling `.col` file, if one was loaded.
5361 /// Index is original `.nl` column order.
5362 fn variable_name(&self, i: usize) -> Option<&str> {
5363 self.prob.var_names.get(i).map(String::as_str)
5364 }
5365
5366 /// Constraint name from the sibling `.row` file, if one was loaded.
5367 /// Index is original `.nl` row order.
5368 fn constraint_name(&self, i: usize) -> Option<&str> {
5369 self.prob.con_names.get(i).map(String::as_str)
5370 }
5371}
5372
5373impl TNLP for NlTnlp {
5374 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
5375 Some(NlpInfo {
5376 n: self.prob.n as Index,
5377 m: self.prob.m as Index,
5378 nnz_jac_g: self.jac_nnz as Index,
5379 nnz_h_lag: self.h_irow.len() as Index,
5380 index_style: IndexStyle::C,
5381 })
5382 }
5383
5384 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
5385 b.x_l.copy_from_slice(&self.prob.x_l);
5386 b.x_u.copy_from_slice(&self.prob.x_u);
5387 if !self.prob.g_l.is_empty() {
5388 b.g_l.copy_from_slice(&self.prob.g_l);
5389 b.g_u.copy_from_slice(&self.prob.g_u);
5390 }
5391 true
5392 }
5393
5394 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
5395 sp.x.copy_from_slice(&self.prob.x0);
5396 // The `.nl` `d` segment supplies initial constraint multipliers
5397 // (`lambda0`). Honor a warm-start request — `init_lambda` is set by
5398 // the engine when `warm_start_init_point yes` — by handing them
5399 // back; `OrigIpoptNlp::get_starting_point` then compresses them into
5400 // the algorithm-side y_c / y_d. Without this the warm start silently
5401 // began from zero multipliers, discarding the parsed duals. (Code
5402 // review 2026-06 item M19.) The `.nl` `d` segment carries no bound
5403 // multipliers, so `z_l`/`z_u` are left to the engine's defaults.
5404 if sp.init_lambda {
5405 sp.lambda.copy_from_slice(&self.prob.lambda0);
5406 }
5407 true
5408 }
5409
5410 /// Hand the `.nl` file's `scaling_factor` suffixes to the engine's
5411 /// `nlp_scaling_method=user-scaling` pathway — the AMPL/ASL channel
5412 /// Ipopt reads in `AmplTNLP::GetScalingParameters`, and the one a
5413 /// Pyomo `Suffix(direction=Suffix.EXPORT)` named `scaling_factor`
5414 /// writes into. Before gh#483 nothing implemented this callback for
5415 /// `.nl` input, so a tagged model reached the solver with the option
5416 /// accepted and *no* scaling applied, silently.
5417 ///
5418 /// Returns `false` (engine falls back to no scaling) when the file
5419 /// declares no `scaling_factor` suffix at all — the same "user
5420 /// supplied nothing" answer as the default `TNLP` impl.
5421 ///
5422 /// AMPL suffix vectors default to **0** for components the model did
5423 /// not tag, and 0 is not a usable scale factor. A zero entry is
5424 /// therefore read as "not tagged" and becomes 1.0, which is what
5425 /// "unlisted components are unscaled" means. Per-variable factors
5426 /// are passed straight through: `OrigIpoptNlp` does not model them
5427 /// and refuses the solve with a message rather than dropping them.
5428 fn get_scaling_parameters(&mut self, req: ScalingRequest<'_>) -> bool {
5429 const NAME: &str = "scaling_factor";
5430 let sfx = &self.prob.suffixes;
5431 let obj = sfx.obj_real.get(NAME);
5432 let var = sfx.var_real.get(NAME);
5433 let con = sfx.con_real.get(NAME);
5434 let computed = self.curvature_scaling.as_ref();
5435 if obj.is_none() && var.is_none() && con.is_none() && computed.is_none() {
5436 return false;
5437 }
5438 // Curvature-based factors (gh #703) are the *base*; a
5439 // `scaling_factor` suffix the model actually carries overrides
5440 // them component by component, because an explicit factor from the
5441 // modeller beats one inferred from the coefficients. A model with
5442 // no suffixes is the ordinary case and gets the computed vectors
5443 // whole.
5444 if let Some(sc) = computed {
5445 // The length guards are a `copy_from_slice` panic guard, not a
5446 // policy: `curvature_scaling` sizes both vectors from the same
5447 // `NlProblem` this callback is answering for, so a mismatch is a
5448 // bug upstream, not a model the scheme declines. Declining is the
5449 // *worst* available response to it — `use_*_scaling` stays false,
5450 // the engine reads that as "user supplied nothing", and the run
5451 // proceeds unscaled with the option accepted, which is gh #483
5452 // again. Assert it in debug builds so a mismatch is found here,
5453 // where it is one line, instead of as a slow solve later.
5454 debug_assert_eq!(
5455 sc.x.len(),
5456 req.x_scaling.len(),
5457 "curvature x-scaling sized {} for a {}-variable request",
5458 sc.x.len(),
5459 req.x_scaling.len()
5460 );
5461 debug_assert_eq!(
5462 sc.g.len(),
5463 req.g_scaling.len(),
5464 "curvature g-scaling sized {} for a {}-row request",
5465 sc.g.len(),
5466 req.g_scaling.len()
5467 );
5468 if sc.x.len() == req.x_scaling.len() {
5469 req.x_scaling.copy_from_slice(&sc.x);
5470 *req.use_x_scaling = true;
5471 }
5472 if sc.g.len() == req.g_scaling.len() {
5473 req.g_scaling.copy_from_slice(&sc.g);
5474 *req.use_g_scaling = true;
5475 }
5476 }
5477 // Objective 0 is the one `NlTnlp` evaluates (extra `O` segments
5478 // are parsed and ignored), so its entry is the objective scale.
5479 *req.obj_scaling = obj
5480 .and_then(|v| v.first().copied())
5481 .filter(|&s| s != 0.0)
5482 .unwrap_or(1.0);
5483 // A zero entry is AMPL's "untagged" default, not a scale factor.
5484 // An untagged component therefore falls back to the base: the
5485 // curvature factor when one was computed, and an explicit 1.0
5486 // otherwise — explicit because the callback's contract is to fill
5487 // the buffer, not to assume the caller pre-filled it with ones.
5488 if let Some(v) = var.filter(|v| v.len() == req.x_scaling.len()) {
5489 for (slot, &s) in req.x_scaling.iter_mut().zip(v) {
5490 if s != 0.0 {
5491 *slot = s;
5492 } else if computed.is_none() {
5493 *slot = 1.0;
5494 }
5495 }
5496 *req.use_x_scaling = true;
5497 } else if computed.is_none() {
5498 *req.use_x_scaling = false;
5499 }
5500 if let Some(g) = con.filter(|g| g.len() == req.g_scaling.len()) {
5501 for (slot, &s) in req.g_scaling.iter_mut().zip(g) {
5502 if s != 0.0 {
5503 *slot = s;
5504 } else if computed.is_none() {
5505 *slot = 1.0;
5506 }
5507 }
5508 *req.use_g_scaling = true;
5509 } else if computed.is_none() {
5510 *req.use_g_scaling = false;
5511 }
5512 true
5513 }
5514
5515 fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
5516 // Reuse the shared forward-value arena (sized to `max_tape_n`) so
5517 // each summand sweep allocates nothing — see `Tape::eval_into`.
5518 let (obj_tapes, vals) = (&self.obj_tapes, &mut self.vals_scratch);
5519 let mut nl: Number = 0.0;
5520 for t in obj_tapes {
5521 nl += t.eval_into(x, vals);
5522 }
5523 // A recognized objective has no tapes, so the loop above added
5524 // nothing and the form supplies the whole nonlinear part — including
5525 // the linear and constant terms AMPL folded into that tree, which is
5526 // why they are *not* also in `obj_linear` / `obj_constant`.
5527 if let Some(f) = self.quad.objective_form() {
5528 nl += self.quad.value(f, x);
5529 }
5530 let lin: Number = self.prob.obj_linear.iter().map(|(i, c)| c * x[*i]).sum();
5531 let v = self.prob.obj_constant + nl + lin;
5532 let signed = if self.prob.minimize { v } else { -v };
5533 Some(signed)
5534 }
5535
5536 fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
5537 grad.fill(0.0);
5538 // Reuse the forward-value / adjoint scratch arenas (sized to
5539 // `max_tape_n`) so each summand tape's reverse-AD sweep allocates
5540 // nothing — see `Tape::gradient_seed_into` (M18).
5541 for t in &self.obj_tapes {
5542 t.gradient_seed_into(x, 1.0, grad, &mut self.vals_scratch, &mut self.adj_scratch);
5543 }
5544 if let Some(f) = self.quad.objective_form() {
5545 self.quad.add_gradient(f, x, 1.0, grad);
5546 }
5547 for (i, c) in &self.prob.obj_linear {
5548 grad[*i] += c;
5549 }
5550 if !self.prob.minimize {
5551 for g in grad.iter_mut() {
5552 *g = -*g;
5553 }
5554 }
5555 true
5556 }
5557
5558 fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
5559 // Constraint values are the line search's inner loop: on a
5560 // constraint-heavy model (m >> n) this runs ~10x per iteration over
5561 // every summand tape in the problem. Reuse the shared forward-value
5562 // arena so the sweep allocates nothing — the per-summand `Vec` the
5563 // allocating `Tape::eval` used to build was ~20% of `eval_g` on
5564 // Mittelmann's `robot_a` (52013 rows / 148037 summands). See
5565 // `Tape::eval_into`.
5566 let m = self.prob.m;
5567 let con_linear = &self.prob.con_linear;
5568 let quad = &self.quad;
5569 if let Some(h) = &mut self.con_hybrid {
5570 // Shared CSE bodies once for the whole constraint block, then one
5571 // local sweep per summand (pounce#476).
5572 let ConHybrid {
5573 tape,
5574 row_start,
5575 prelude_vals,
5576 local_vals,
5577 ..
5578 } = h;
5579 tape.forward_prelude(x, prelude_vals);
5580 for i in 0..m {
5581 let mut nl: Number = 0.0;
5582 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
5583 tape.forward_summand(s, x, prelude_vals, local_vals);
5584 nl += tape.root_value(s, local_vals);
5585 }
5586 if let Some(f) = quad.row_form(i) {
5587 nl += quad.value(f, x);
5588 }
5589 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
5590 g[i] = nl + lin;
5591 }
5592 return true;
5593 }
5594 let (con_tapes, vals) = (&self.con_tapes, &mut self.vals_scratch);
5595 for i in 0..m {
5596 let mut nl: Number = 0.0;
5597 for t in &con_tapes[i] {
5598 nl += t.eval_into(x, vals);
5599 }
5600 // A quadratic row's summand range is empty above, so this is its
5601 // whole nonlinear part: one matvec against a constant matrix in
5602 // place of a walk over every monomial's tape.
5603 if let Some(f) = quad.row_form(i) {
5604 nl += quad.value(f, x);
5605 }
5606 let lin: Number = con_linear[i].iter().map(|(j, c)| c * x[*j]).sum();
5607 g[i] = nl + lin;
5608 }
5609 true
5610 }
5611
5612 fn eval_jac_g(
5613 &mut self,
5614 x: Option<&[Number]>,
5615 _new_x: bool,
5616 mode: SparsityRequest<'_>,
5617 ) -> bool {
5618 match mode {
5619 SparsityRequest::Structure { irow, jcol } => {
5620 let mut k = 0;
5621 for i in 0..self.prob.m {
5622 for &j in &self.jac_cols[i] {
5623 irow[k] = i as Index;
5624 jcol[k] = j as Index;
5625 k += 1;
5626 }
5627 }
5628 true
5629 }
5630 SparsityRequest::Values { values } => {
5631 let n = self.prob.n;
5632 if self.scratch_row_grad.len() < n {
5633 self.scratch_row_grad.resize(n, 0.0);
5634 }
5635 let Self {
5636 prob,
5637 con_tapes,
5638 con_hybrid,
5639 quad,
5640 jac_cols,
5641 scratch_row_grad,
5642 vals_scratch,
5643 adj_scratch,
5644 ..
5645 } = self;
5646 let xs = x.unwrap_or(&prob.x0);
5647 let mut k = 0;
5648 // Shared-CSE path: the forward sweep over the CSE bodies runs
5649 // once for the whole constraint block instead of once per
5650 // referencing summand. The reverse sweep cannot be shared —
5651 // each row needs its own gradient — so a summand still walks
5652 // its own `prelude_reach` backwards.
5653 if let Some(h) = con_hybrid.as_mut().filter(|h| h.use_for_jac) {
5654 let ConHybrid {
5655 tape,
5656 row_start,
5657 prelude_vals,
5658 local_vals,
5659 local_adj,
5660 prelude_adj,
5661 ..
5662 } = h;
5663 tape.forward_prelude(xs, prelude_vals);
5664 for i in 0..prob.m {
5665 for &j in &jac_cols[i] {
5666 scratch_row_grad[j] = 0.0;
5667 }
5668 for s in &tape.summands[row_start[i]..row_start[i + 1]] {
5669 tape.forward_summand(s, xs, prelude_vals, local_vals);
5670 tape.gradient_summand(
5671 s,
5672 prelude_vals,
5673 local_vals,
5674 1.0,
5675 scratch_row_grad,
5676 local_adj,
5677 prelude_adj,
5678 );
5679 }
5680 if let Some(f) = quad.row_form(i) {
5681 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
5682 }
5683 for &(v, c) in &prob.con_linear[i] {
5684 scratch_row_grad[v] += c;
5685 }
5686 for &j in &jac_cols[i] {
5687 values[k] = scratch_row_grad[j];
5688 k += 1;
5689 }
5690 }
5691 return true;
5692 }
5693 for i in 0..prob.m {
5694 for &j in &jac_cols[i] {
5695 scratch_row_grad[j] = 0.0;
5696 }
5697 for t in &con_tapes[i] {
5698 // Allocation-free reverse-AD per summand tape (M18):
5699 // reuse the shared forward/adjoint scratch arenas.
5700 t.gradient_seed_into(xs, 1.0, scratch_row_grad, vals_scratch, adj_scratch);
5701 }
5702 // `Hx + a` for a quadratic row: one matvec over the
5703 // row's support, in place of a reverse sweep per
5704 // monomial. `jac_cols[i]` already covers the form's
5705 // gradient support, so the scatter lands inside the
5706 // window zeroed above.
5707 if let Some(f) = quad.row_form(i) {
5708 quad.add_gradient(f, xs, 1.0, scratch_row_grad);
5709 }
5710 for &(v, c) in &prob.con_linear[i] {
5711 scratch_row_grad[v] += c;
5712 }
5713 for &j in &jac_cols[i] {
5714 values[k] = scratch_row_grad[j];
5715 k += 1;
5716 }
5717 }
5718 true
5719 }
5720 }
5721 }
5722
5723 fn eval_h(
5724 &mut self,
5725 x: Option<&[Number]>,
5726 _new_x: bool,
5727 obj_factor: Number,
5728 lambda: Option<&[Number]>,
5729 _new_lambda: bool,
5730 mode: SparsityRequest<'_>,
5731 ) -> bool {
5732 match mode {
5733 SparsityRequest::Structure { irow, jcol } => {
5734 irow.copy_from_slice(&self.h_irow);
5735 jcol.copy_from_slice(&self.h_jcol);
5736 true
5737 }
5738 SparsityRequest::Values { values } => {
5739 let x = x.unwrap_or(&self.prob.x0);
5740 values.fill(0.0);
5741
5742 let obj_seed = if self.prob.minimize {
5743 obj_factor
5744 } else {
5745 -obj_factor
5746 };
5747 // Coloring path. For each (tape, weight) we do
5748 // one forward pass into `vals_scratch`, then one
5749 // forward-tangent+reverse-over-tangent per color
5750 // touched by that tape. Each pass accumulates a
5751 // weighted contribution of (H_tape · seed_c) into
5752 // `compressed[c]`. After all tapes done, we
5753 // decode each color's compressed vector into the
5754 // sparse `values` array.
5755 for buf in &mut self.compressed {
5756 buf.fill(0.0);
5757 }
5758
5759 // The constant blocks first: no forward sweep, no
5760 // directional product, no decode — the multipliers are the
5761 // only thing that changed since the model was read, so this
5762 // is one `values[slot] += w · h` pass per live form. Skipped
5763 // wholesale on a model with nothing recognized, so such a
5764 // model does not pay an `O(m)` scan for a structure that is
5765 // empty.
5766 if !self.quad.is_empty() {
5767 if obj_seed != 0.0 {
5768 if let Some(f) = self.quad.objective_form() {
5769 self.quad.accumulate_hessian(f, obj_seed, values);
5770 }
5771 }
5772 if let Some(lam) = lambda {
5773 for i in 0..self.prob.m {
5774 let w = lam[i];
5775 if w == 0.0 {
5776 continue;
5777 }
5778 if let Some(f) = self.quad.row_form(i) {
5779 self.quad.accumulate_hessian(f, w, values);
5780 }
5781 }
5782 }
5783 }
5784
5785 if obj_seed != 0.0 {
5786 for (ti, t) in self.obj_tapes.iter().enumerate() {
5787 if t.ops.is_empty() {
5788 continue;
5789 }
5790 t.forward_into(x, &mut self.vals_scratch);
5791 for &c in &self.obj_tape_colors[ti] {
5792 t.hessian_directional(
5793 &self.vals_scratch,
5794 &self.seeds[c as usize],
5795 obj_seed,
5796 &mut self.compressed[c as usize],
5797 &mut self.dot_scratch,
5798 &mut self.adj_scratch,
5799 &mut self.adj_dot_scratch,
5800 );
5801 }
5802 }
5803 }
5804
5805 match (lambda, self.con_hybrid.as_mut()) {
5806 // Shared-CSE path (issue #557). Per color the prelude's
5807 // second-order work runs ONCE for the whole constraint
5808 // block: one forward tangent, then — because
5809 // reverse-over-tangent is linear in its adjoint seeds —
5810 // one unit-weight reverse sweep over the λ-weighted
5811 // adjoints accumulated by every summand of that color.
5812 // The flat path below repeats both sweeps over the
5813 // inlined CSE body once per referencing summand.
5814 (Some(lam), Some(h)) if h.use_for_hess => {
5815 let ConHybrid {
5816 tape,
5817 prelude_vals,
5818 local_vals_all,
5819 local_off,
5820 summand_row,
5821 hess_color_summands,
5822 hess_color_reach,
5823 hess_color_reach_off,
5824 prelude_dot,
5825 hess_prelude_adj,
5826 prelude_adj_dot,
5827 local_dot,
5828 local_adj,
5829 local_adj_dot,
5830 ..
5831 } = h;
5832 // Forward once (values are color-independent):
5833 // prelude for the block, then each summand of a row
5834 // with a live multiplier into its packed slice.
5835 tape.forward_prelude(x, prelude_vals);
5836 for (si, s) in tape.summands.iter().enumerate() {
5837 if lam[summand_row[si] as usize] == 0.0 {
5838 continue;
5839 }
5840 tape.forward_summand(
5841 s,
5842 x,
5843 prelude_vals,
5844 &mut local_vals_all[local_off[si]..local_off[si + 1]],
5845 );
5846 }
5847 for (c, list) in hess_color_summands.iter().enumerate() {
5848 if !list
5849 .iter()
5850 .any(|&si| lam[summand_row[si as usize] as usize] != 0.0)
5851 {
5852 continue;
5853 }
5854 let seed = &self.seeds[c];
5855 let out = &mut self.compressed[c];
5856 let creach = &hess_color_reach
5857 [hess_color_reach_off[c]..hess_color_reach_off[c + 1]];
5858 tape.prelude_tangent(prelude_vals, seed, creach, prelude_dot);
5859 for &si in list {
5860 let si = si as usize;
5861 let w = lam[summand_row[si] as usize];
5862 if w == 0.0 {
5863 continue;
5864 }
5865 tape.hessian_summand_directional(
5866 &tape.summands[si],
5867 &local_vals_all[local_off[si]..local_off[si + 1]],
5868 prelude_dot,
5869 seed,
5870 w,
5871 out,
5872 local_dot,
5873 local_adj,
5874 local_adj_dot,
5875 hess_prelude_adj,
5876 prelude_adj_dot,
5877 );
5878 }
5879 tape.prelude_reverse_directional(
5880 prelude_vals,
5881 prelude_dot,
5882 creach,
5883 out,
5884 hess_prelude_adj,
5885 prelude_adj_dot,
5886 );
5887 }
5888 }
5889 (Some(lam), _) => {
5890 for k in 0..self.prob.m {
5891 let w = lam[k];
5892 if w == 0.0 {
5893 continue;
5894 }
5895 for (ti, t) in self.con_tapes[k].iter().enumerate() {
5896 if t.ops.is_empty() {
5897 continue;
5898 }
5899 t.forward_into(x, &mut self.vals_scratch);
5900 for &c in &self.con_tape_colors[k][ti] {
5901 t.hessian_directional(
5902 &self.vals_scratch,
5903 &self.seeds[c as usize],
5904 w,
5905 &mut self.compressed[c as usize],
5906 &mut self.dot_scratch,
5907 &mut self.adj_scratch,
5908 &mut self.adj_dot_scratch,
5909 );
5910 }
5911 }
5912 }
5913 }
5914 (None, _) => {}
5915 }
5916
5917 // Decode each color's compressed Hessian-vector
5918 // result into the lower-triangle `values` array.
5919 for (c, table) in self.decoding.iter().enumerate() {
5920 let comp = &self.compressed[c];
5921 for w in table {
5922 values[w.hess_idx as usize] += comp[w.row as usize];
5923 }
5924 }
5925 true
5926 }
5927 }
5928 }
5929
5930 fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
5931 self.final_x = Some(sol.x.to_vec());
5932 self.final_obj = sol.obj_value;
5933 self.final_lambda = Some(sol.lambda.to_vec());
5934 self.final_z_l = Some(sol.z_l.to_vec());
5935 self.final_z_u = Some(sol.z_u.to_vec());
5936 }
5937
5938 /// Publish the `.col` / `.row` names (captured at load time) under the
5939 /// conventional `idx_names` metadata key, in original `.nl` order. The
5940 /// adapter permutes these into split space (see
5941 /// `OrigIpoptNlp::split_space_names`) so the debugger can report a
5942 /// near-singular Jacobian row as the `mass_balance` equation rather
5943 /// than "row 3" — the model-vs-index gap Lee et al. (2024,
5944 /// <https://doi.org/10.69997/sct.147875>) flag for equation-oriented
5945 /// model debugging. Declines (returns false) when the model shipped no
5946 /// name files so callers fall back to index labels.
5947 fn get_var_con_metadata(&mut self, var: &mut MetaData, con: &mut MetaData) -> bool {
5948 let mut any = false;
5949 if !self.prob.var_names.is_empty() {
5950 var.strings
5951 .insert(IDX_NAMES.to_string(), self.prob.var_names.clone());
5952 any = true;
5953 }
5954 if !self.prob.con_names.is_empty() {
5955 con.strings
5956 .insert(IDX_NAMES.to_string(), self.prob.con_names.clone());
5957 any = true;
5958 }
5959 any
5960 }
5961
5962 fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
5963 // A row is linear iff its nonlinear-part expression is the
5964 // identity zero — either left over from initial allocation ("no
5965 // `C<idx>` segment touched this row") or installed by the
5966 // constant-row-body fold in `parse_nl_text`, which shifts a
5967 // variable-free `C<idx>` body into the row bounds precisely so
5968 // that this test is a genuine linearity test and not just an
5969 // identity check (`gh #492`).
5970 for (i, t) in types.iter_mut().enumerate() {
5971 *t = if self.prob.con_nonlinear[i].is_trivially_zero() {
5972 Linearity::Linear
5973 } else {
5974 Linearity::NonLinear
5975 };
5976 }
5977 true
5978 }
5979
5980 fn get_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
5981 // Global linearity, per the upstream TNLP contract: a variable is
5982 // NonLinear iff it appears in the nonlinear part of the objective
5983 // or of any constraint; otherwise Linear. The parsed `.nl` splits
5984 // every row into a linear part (J/G coefficient list) and a
5985 // nonlinear expression, so the set of nonlinear variables is
5986 // exactly the structural union of `collect_vars` over
5987 // `obj_nonlinear` and every `con_nonlinear` row. A variable touched
5988 // only by a linear part — or not referenced at all — is Linear.
5989 //
5990 let nonlinear = self.nonlinear_var_set();
5991 for (i, t) in types.iter_mut().enumerate() {
5992 *t = if nonlinear.contains(&i) {
5993 Linearity::NonLinear
5994 } else {
5995 Linearity::Linear
5996 };
5997 }
5998 true
5999 }
6000
6001 fn get_objective_variables_linearity(&mut self, types: &mut [Linearity]) -> bool {
6002 // Objective-scoped variant of `get_variables_linearity`: only
6003 // `obj_nonlinear` contributes. This is what engages the presolve
6004 // auxiliary-elimination safeguard (pounce-presolve H11): a variable
6005 // that is nonlinear in the objective but happens to have a zero
6006 // gradient at the single probe point (e.g. `f = (x - x0)^2`
6007 // warm-started at `x0`) is kept in the objective support instead of
6008 // being mis-classified objective-free and eliminated. A variable
6009 // that is nonlinear only in *constraints* stays `Linear` here, so
6010 // the guard does not block legitimate eliminations of
6011 // objective-free equality blocks (the gas-network case).
6012 let mut nonlinear: BTreeSet<usize> = BTreeSet::new();
6013 self.prob.obj_nonlinear.collect_vars(&mut nonlinear);
6014 for (i, t) in types.iter_mut().enumerate() {
6015 *t = if nonlinear.contains(&i) {
6016 Linearity::NonLinear
6017 } else {
6018 Linearity::Linear
6019 };
6020 }
6021 true
6022 }
6023
6024 fn get_number_of_nonlinear_variables(&mut self) -> Index {
6025 self.nonlinear_variables().len() as Index
6026 }
6027
6028 fn get_list_of_nonlinear_variables(&mut self, pos_nonlin_vars: &mut [Index]) -> bool {
6029 let list = self.nonlinear_variables();
6030 if pos_nonlin_vars.len() < list.len() {
6031 return false;
6032 }
6033 pos_nonlin_vars[..list.len()].copy_from_slice(&list);
6034 true
6035 }
6036
6037 fn derivative_proofs(&mut self) -> DerivativeProofs {
6038 // Degree is the whole argument (gh #588, Q6). A body proved
6039 // degree ≤ 1 has a constant gradient and no second derivative; a
6040 // body proved degree 2 has a nonzero second derivative, hence a
6041 // gradient that moves. A body the recognizer refuses is
6042 // `Unknown` — and `Unknown` must stay `Unknown`, because the
6043 // refusal is structural, not a finding of nonlinearity.
6044 fn proof(affine: Option<bool>) -> DerivativeProof {
6045 match affine {
6046 Some(true) => DerivativeProof::Constant,
6047 Some(false) => DerivativeProof::Varying,
6048 None => DerivativeProof::Unknown,
6049 }
6050 }
6051 let obj_affine = self.prob.obj_nonlinear.provably_affine();
6052 let jac: Vec<DerivativeProof> = self
6053 .prob
6054 .con_nonlinear
6055 .iter()
6056 .map(|b| proof(b.provably_affine()))
6057 .collect();
6058
6059 // `∇²L = σ·∇²f + Σᵢ λᵢ·∇²gᵢ`. One row proved genuinely quadratic
6060 // makes `∇²L` a non-constant function of `λ` — this is the QCQP
6061 // case, and it is exactly the assertion Ipopt would honour and
6062 // get wrong (§4 Lever 2). Otherwise every row must be *proved*
6063 // affine and the objective *proved* degree ≤ 2, at which point
6064 // `∇²L = σ·∇²f`, constant for a given `σ`; the caller keys its
6065 // reuse on `σ` because the restoration phase passes a different
6066 // one.
6067 let hessian = if jac.iter().any(|&p| p == DerivativeProof::Varying) {
6068 DerivativeProof::Varying
6069 } else if obj_affine.is_some() && jac.iter().all(|&p| p == DerivativeProof::Constant) {
6070 DerivativeProof::Constant
6071 } else {
6072 DerivativeProof::Unknown
6073 };
6074
6075 DerivativeProofs {
6076 grad_f: proof(obj_affine),
6077 hessian,
6078 jac,
6079 }
6080 }
6081}
6082
6083/// Convenience: read an `.nl` file and build a TNLP-compatible Rc.
6084pub fn load_nl_as_tnlp(path: &Path) -> Result<Rc<RefCell<dyn TNLP>>, String> {
6085 let prob = read_nl_file(path)?;
6086 Ok(Rc::new(RefCell::new(NlTnlp::new(prob))))
6087}
6088
6089#[cfg(test)]
6090mod tests {
6091 use super::*;
6092
6093 /// Compile-time guarantee for the batched-solve path (pounce#126):
6094 /// a parsed problem and the TNLP built from it must be movable to a
6095 /// rayon worker. Regresses if anyone reintroduces an `Rc` (or other
6096 /// `!Send` state) into the `Expr` DAG / tape pipeline.
6097 #[test]
6098 fn nl_problem_and_tnlp_are_send() {
6099 fn assert_send<T: Send>() {}
6100 assert_send::<NlProblem>();
6101 assert_send::<NlTnlp>();
6102 assert_send::<Expr>();
6103 }
6104
6105 /// `variant()` patches starting point / bounds on a clone and
6106 /// validates override lengths; the base instance is untouched.
6107 #[test]
6108 fn variant_overrides_bounds_and_x0() {
6109 let p = parse_nl_text(SIMPLE).expect("parse");
6110 let base = NlTnlp::new(p);
6111 let var = base
6112 .variant(&NlVariation {
6113 x0: Some(vec![3.0, 4.0]),
6114 x_l: Some(vec![-1.0, -2.0]),
6115 x_u: Some(vec![5.0, 6.0]),
6116 ..Default::default()
6117 })
6118 .expect("variant");
6119 let mut var = var;
6120 let (mut x_l, mut x_u) = ([0.0; 2], [0.0; 2]);
6121 let (mut g_l, mut g_u) = ([0.0; 0], [0.0; 0]);
6122 assert!(var.get_bounds_info(BoundsInfo {
6123 x_l: &mut x_l,
6124 x_u: &mut x_u,
6125 g_l: &mut g_l,
6126 g_u: &mut g_u,
6127 }));
6128 assert_eq!(x_l, [-1.0, -2.0]);
6129 assert_eq!(x_u, [5.0, 6.0]);
6130 let mut x = [0.0; 2];
6131 let (mut zl, mut zu, mut lam) = ([0.0; 2], [0.0; 2], [0.0; 0]);
6132 assert!(var.get_starting_point(StartingPoint {
6133 init_x: true,
6134 x: &mut x,
6135 init_z: false,
6136 z_l: &mut zl,
6137 z_u: &mut zu,
6138 init_lambda: false,
6139 lambda: &mut lam,
6140 }));
6141 assert_eq!(x, [3.0, 4.0]);
6142 // Base keeps its parsed (free) bounds.
6143 assert!(base.problem().x_l[0] < -1.0e18);
6144 // Length mismatch is an error, not a panic.
6145 assert!(
6146 base.variant(&NlVariation {
6147 x0: Some(vec![1.0]),
6148 ..Default::default()
6149 })
6150 .is_err()
6151 );
6152 }
6153
6154 /// `min (x0 - 1)^2 + (x1 - 2)^2` written in `.nl` ASCII form.
6155 /// Header values:
6156 /// line 2: n=2 m=0 num_obj=1 0 0
6157 /// line 3: 0 1 (1 nonlinear objective)
6158 /// line 4: 0 0
6159 /// line 5: 0 2 0 (nonlinear vars in obj=2)
6160 /// line 6: 0 0 0 1
6161 /// line 7: 0 0 0 0 0
6162 /// line 8: 0 0 (no Jacobian nonzeros, no linear obj)
6163 /// line 9: 0 0
6164 /// line 10: 0 0 0 0 0
6165 /// Then `O0 0` followed by an expression tree:
6166 /// `(x0 - 1)^2 + (x1 - 2)^2` =
6167 /// o0
6168 /// o5 (o1 v0 n1) n2
6169 /// o5 (o1 v1 n2) n2
6170 /// Then `b` segment: free for both.
6171 const SIMPLE: &str = "g3 0 1 0
61722 0 1 0 0
61730 1
61740 0
61750 2 0
61760 0 0 1
61770 0 0 0 0
61780 0
61790 0
61800 0 0 0 0
6181O0 0
6182o0
6183o5
6184o1
6185v0
6186n1
6187n2
6188o5
6189o1
6190v1
6191n2
6192n2
6193b
61943
61953
6196";
6197
6198 #[test]
6199 fn parses_simple_quadratic() {
6200 let p = parse_nl_text(SIMPLE).expect("parse");
6201 assert_eq!(p.n, 2);
6202 assert_eq!(p.m, 0);
6203 assert_eq!(p.num_obj, 1);
6204 // f(0,0) = 1 + 4 = 5
6205 let f = eval_expr(&p.obj_expr(), &[0.0, 0.0]);
6206 assert!((f - 5.0).abs() < 1e-12);
6207 // f(1,2) = 0
6208 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
6209 assert!(f.abs() < 1e-12);
6210 }
6211
6212 #[test]
6213 fn gradient_matches_analytic() {
6214 let p = parse_nl_text(SIMPLE).expect("parse");
6215 let x = [0.5, 1.0];
6216 let mut g = [0.0_f64; 2];
6217 grad_expr(&p.obj_expr(), &x, 1.0, &mut g);
6218 // d/dx0 = 2*(x0-1) = -1.0
6219 // d/dx1 = 2*(x1-2) = -2.0
6220 assert!((g[0] - (-1.0)).abs() < 1e-12);
6221 assert!((g[1] - (-2.0)).abs() < 1e-12);
6222 }
6223
6224 /// F3 (H11 dormant): `NlTnlp` must answer `get_variables_linearity`
6225 /// with global semantics so the presolve auxiliary-elimination
6226 /// safeguard actually engages. Pre-fix the default trait stub returned
6227 /// `false` and left the slice untouched, so a variable that is
6228 /// nonlinear in the objective but zero-gradient at the probe point
6229 /// could be wrongly eliminated.
6230 ///
6231 /// Problem: `min (x0 - 1)^2 + 3*x1`. x0 appears in the nonlinear part
6232 /// of the objective (NonLinear); x1 appears only in the linear part
6233 /// (Linear).
6234 #[test]
6235 fn variables_linearity_tags_obj_nonlinear_vs_linear_vars() {
6236 // (x0 - 1)^2
6237 let obj_nl = Expr::Binary(
6238 BinOp::Pow,
6239 Box::new(Expr::Binary(
6240 BinOp::Sub,
6241 Box::new(Expr::Var(0)),
6242 Box::new(Expr::Const(1.0)),
6243 )),
6244 Box::new(Expr::Const(2.0)),
6245 );
6246 let prob = NlProblem {
6247 src: None,
6248 cse_bodies: Vec::new(),
6249 n: 2,
6250 m: 0,
6251 num_obj: 1,
6252 minimize: true,
6253 obj_nonlinear: NlBody::Tree(obj_nl),
6254 obj_linear: vec![(1, 3.0)],
6255 obj_constant: 0.0,
6256 con_nonlinear: vec![],
6257 con_linear: vec![],
6258 x_l: vec![f64::NEG_INFINITY; 2],
6259 x_u: vec![f64::INFINITY; 2],
6260 g_l: vec![],
6261 g_u: vec![],
6262 x0: vec![0.0; 2],
6263 lambda0: vec![],
6264 suffixes: NlSuffixes::default(),
6265 imported_funcs: vec![],
6266 ampl_options: vec![],
6267 nl_counts: None,
6268 var_names: vec![],
6269 con_names: vec![],
6270 };
6271 let mut tnlp = NlTnlp::new(prob);
6272 let mut types = vec![Linearity::Linear; 2];
6273 let ok = tnlp.get_variables_linearity(&mut types);
6274 // Pre-fix: default stub returns false (slice untouched).
6275 assert!(
6276 ok,
6277 "get_variables_linearity must report it filled the slice"
6278 );
6279 assert!(
6280 matches!(types[0], Linearity::NonLinear),
6281 "x0 is nonlinear in the objective"
6282 );
6283 assert!(
6284 matches!(types[1], Linearity::Linear),
6285 "x1 appears only in the linear part"
6286 );
6287 }
6288
6289 /// Objective-scoped linearity must NOT inherit constraint
6290 /// nonlinearity. `min 3*x1 s.t. x0^2 = 4`: x0 is nonlinear globally
6291 /// (constraint tape) but linear w.r.t. the objective, so the presolve
6292 /// H11 guard must not treat it as objective-coupled — that was the CI
6293 /// regression where every gas-network variable (nonlinear in the flow
6294 /// equations, absent from the linear objective) blocked Phase-0
6295 /// elimination.
6296 #[test]
6297 fn objective_variables_linearity_ignores_constraint_nonlinearity() {
6298 // x0^2
6299 let con_nl = Expr::Binary(
6300 BinOp::Pow,
6301 Box::new(Expr::Var(0)),
6302 Box::new(Expr::Const(2.0)),
6303 );
6304 let prob = NlProblem {
6305 src: None,
6306 cse_bodies: Vec::new(),
6307 n: 2,
6308 m: 1,
6309 num_obj: 1,
6310 minimize: true,
6311 obj_nonlinear: NlBody::Tree(Expr::Const(0.0)),
6312 obj_linear: vec![(1, 3.0)],
6313 obj_constant: 0.0,
6314 con_nonlinear: vec![NlBody::Tree(con_nl)],
6315 con_linear: vec![vec![]],
6316 x_l: vec![f64::NEG_INFINITY; 2],
6317 x_u: vec![f64::INFINITY; 2],
6318 g_l: vec![4.0],
6319 g_u: vec![4.0],
6320 x0: vec![0.0; 2],
6321 lambda0: vec![0.0],
6322 suffixes: NlSuffixes::default(),
6323 imported_funcs: vec![],
6324 ampl_options: vec![],
6325 nl_counts: None,
6326 var_names: vec![],
6327 con_names: vec![],
6328 };
6329 let mut tnlp = NlTnlp::new(prob);
6330
6331 let mut global = vec![Linearity::Linear; 2];
6332 assert!(tnlp.get_variables_linearity(&mut global));
6333 assert!(
6334 matches!(global[0], Linearity::NonLinear),
6335 "global tags see x0's constraint nonlinearity"
6336 );
6337
6338 let mut obj = vec![Linearity::NonLinear; 2];
6339 assert!(tnlp.get_objective_variables_linearity(&mut obj));
6340 assert!(
6341 matches!(obj[0], Linearity::Linear),
6342 "x0 is linear w.r.t. the objective despite the nonlinear constraint"
6343 );
6344 assert!(
6345 matches!(obj[1], Linearity::Linear),
6346 "x1 is linear everywhere"
6347 );
6348 }
6349
6350 /// Header lines 3 and 5 land in [`NlCounts`], with the field order the
6351 /// format documents: `nlc nlo` then `nlvc nlvo nlvb`. `SIMPLE` is
6352 /// `min (x0-1)^2 + (x1-2)^2`, so one nonlinear objective, no nonlinear
6353 /// constraints, and both variables nonlinear in the objective only.
6354 #[test]
6355 fn header_census_is_parsed() {
6356 let p = parse_nl_text(SIMPLE).expect("parse");
6357 let c = p.nl_counts.expect("SIMPLE has a well-formed header");
6358 assert_eq!(c.nl_cons, 0);
6359 assert_eq!(c.nl_objs, 1);
6360 assert_eq!((c.nl_vars_cons, c.nl_vars_objs, c.nl_vars_both), (0, 2, 0));
6361 assert_eq!(c.nonlinear_vars(), 2);
6362 }
6363
6364 /// `nlvb` is inside both `nlvc` and `nlvo`, so the total is
6365 /// `nlvc + nlvo − nlvb`. The two degenerate directions matter as much
6366 /// as the overlapping one: disjoint sets add, and `max` would be wrong
6367 /// for them.
6368 #[test]
6369 fn nonlinear_var_total_uses_inclusion_exclusion() {
6370 let c = |vc, vo, vb| NlCounts {
6371 nl_cons: 0,
6372 nl_objs: 0,
6373 nl_vars_cons: vc,
6374 nl_vars_objs: vo,
6375 nl_vars_both: vb,
6376 };
6377 // Fully shared: the same 5 variables in both.
6378 assert_eq!(c(5, 5, 5).nonlinear_vars(), 5);
6379 // Disjoint: `min x0^2 s.t. x1^2 <= 1` is two nonlinear variables,
6380 // not the `max(nlvc, nlvo) = 1` a naive reading gives.
6381 assert_eq!(c(1, 1, 0).nonlinear_vars(), 2);
6382 // Partial overlap: 4 + 3 - 2.
6383 assert_eq!(c(4, 3, 2).nonlinear_vars(), 5);
6384 // Nonsense header: saturates instead of underflowing.
6385 assert_eq!(c(1, 1, 9).nonlinear_vars(), 0);
6386 }
6387
6388 /// A header that does not carry the documented fields records no
6389 /// census at all rather than a guess of zero — "no nonlinear
6390 /// variables" is a claim, and a truncated header has not made it.
6391 #[test]
6392 fn short_header_line_records_no_census() {
6393 // Line 5 with two fields instead of `nlvc nlvo nlvb`.
6394 let txt = SIMPLE.replacen("0 2 0\n", "0 2\n", 1);
6395 assert_ne!(txt, SIMPLE, "the substitution must have applied");
6396 let p = parse_nl_text(&txt).expect("parse");
6397 assert!(p.nl_counts.is_none());
6398 }
6399
6400 /// `get_number_of_nonlinear_variables` used to be the trait default,
6401 /// `-1` ("assume everything is nonlinear"). It now answers from the
6402 /// trees, and `get_list_of_nonlinear_variables` agrees with it.
6403 ///
6404 /// `min (x0 - 1)^2 + 3*x1`: x0 is nonlinear, x1 is not.
6405 #[test]
6406 fn nonlinear_variable_list_excludes_linear_columns() {
6407 let obj_nl = Expr::Binary(
6408 BinOp::Pow,
6409 Box::new(Expr::Binary(
6410 BinOp::Sub,
6411 Box::new(Expr::Var(0)),
6412 Box::new(Expr::Const(1.0)),
6413 )),
6414 Box::new(Expr::Const(2.0)),
6415 );
6416 let parts = NlProblemParts {
6417 minimize: true,
6418 objective: obj_nl,
6419 obj_constant: 0.0,
6420 constraints: vec![],
6421 x_l: vec![-1e19; 2],
6422 x_u: vec![1e19; 2],
6423 x0: vec![0.0; 2],
6424 g_l: vec![],
6425 g_u: vec![],
6426 var_names: vec![],
6427 con_names: vec![],
6428 };
6429 let prob = NlProblem::from_expressions(parts).expect("build");
6430 assert!(
6431 prob.nl_counts.is_none(),
6432 "a model built in memory has no header to read"
6433 );
6434 let mut tnlp = NlTnlp::new(prob);
6435 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 1);
6436 let mut list = [-1 as Index; 2];
6437 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
6438 assert_eq!(list[0], 0);
6439 }
6440
6441 /// When the header says every variable is nonlinear the walk is
6442 /// skipped, and the answer is the same one the walk would give for
6443 /// `SIMPLE` (both variables appear in the objective's nonlinear part).
6444 #[test]
6445 fn all_nonlinear_header_short_circuits_to_n() {
6446 let p = parse_nl_text(SIMPLE).expect("parse");
6447 assert_eq!(p.nl_counts.expect("census").nonlinear_vars(), p.n);
6448 let mut tnlp = NlTnlp::new(p);
6449 assert_eq!(tnlp.get_number_of_nonlinear_variables(), 2);
6450 let mut list = [-1 as Index; 2];
6451 assert!(tnlp.get_list_of_nonlinear_variables(&mut list));
6452 assert_eq!(list, [0, 1]);
6453 }
6454
6455 /// The list must not be written when the caller's slice is too small —
6456 /// the contract's `false` return, not a panic.
6457 #[test]
6458 fn nonlinear_variable_list_declines_a_short_slice() {
6459 let p = parse_nl_text(SIMPLE).expect("parse");
6460 let mut tnlp = NlTnlp::new(p);
6461 let mut list = [-1 as Index; 1];
6462 assert!(!tnlp.get_list_of_nonlinear_variables(&mut list));
6463 assert_eq!(list, [-1]);
6464 }
6465
6466 /// `min x0^2 + x1^2 s.t. x0 + x1 = 1`.
6467 /// One equality constraint with a purely linear Jacobian — exercises
6468 /// the constrained path (`eval_g`, `eval_jac_g`, `r`-segment bound
6469 /// kind 4).
6470 ///
6471 /// Header layout:
6472 /// line 1: g3 0 1 0
6473 /// line 2: 2 1 1 0 0 (n=2, m=1, num_obj=1)
6474 /// line 3: 0 1 (1 nonlinear obj, 0 nonlinear cons)
6475 /// line 4: 0 0
6476 /// line 5: 0 2 0 (nonlinear vars in obj=2)
6477 /// line 6: 0 0 0 1
6478 /// line 7: 0 0 0 0 0
6479 /// line 8: 2 0 (Jacobian nnz=2, no linear obj)
6480 /// line 9: 0 0
6481 /// line 10: 0 0 0 0 0
6482 /// Then C0 = const 0 (no nonlinear part), O0 = x0^2 + x1^2,
6483 /// r-segment kind 4 (eq) value 1, b-segment free, k-segment, J-row.
6484 const EQ_LIN: &str = "g3 0 1 0
64852 1 1 0 0
64860 1
64870 0
64880 2 0
64890 0 0 1
64900 0 0 0 0
64912 0
64920 0
64930 0 0 0 0
6494C0
6495n0
6496O0 0
6497o0
6498o5
6499v0
6500n2
6501o5
6502v1
6503n2
6504r
65054 1
6506b
65073
65083
6509k1
65102
6511J0 2
65120 1
65131 1
6514";
6515
6516 #[test]
6517 fn parses_constrained_problem() {
6518 let p = parse_nl_text(EQ_LIN).expect("parse");
6519 assert_eq!(p.n, 2);
6520 assert_eq!(p.m, 1);
6521 // r-segment kind 4 (equality with rhs=1).
6522 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
6523 assert!((p.g_u[0] - 1.0).abs() < 1e-12);
6524 // J-row 0: x0 (coef 1), x1 (coef 1).
6525 assert_eq!(p.con_linear[0], vec![(0, 1.0), (1, 1.0)]);
6526 }
6527
6528 #[test]
6529 fn malformed_j_variable_index_is_parse_error_not_panic() {
6530 // Code review L32: a J-segment entry's variable (column) index was
6531 // pushed into con_linear unchecked, so an out-of-range index (here 5
6532 // with n=2) flowed through to a slice OOB panic (`x[*j]`) during
6533 // constraint evaluation. It must instead surface as a clean parse
6534 // error, consistent with the existing `J<row> out of range` check.
6535 let bad = EQ_LIN.replace("J0 2\n0 1\n1 1\n", "J0 2\n0 1\n5 1\n");
6536 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
6537 let err = parse_nl_text(&bad).expect_err("out-of-range J var must error");
6538 assert!(err.contains("out of range"), "unexpected error: {err}");
6539 }
6540
6541 #[test]
6542 fn out_of_range_x_segment_index_is_parse_error() {
6543 // Same strictness for the initial-primal `x` segment: an index past
6544 // `n` used to be silently dropped; now it is a parse error, so the
6545 // four index-bearing segments (J/G/x/d) behave consistently.
6546 let bad = format!("{EQ_LIN}x1\n5 0.5\n");
6547 let err = parse_nl_text(&bad).expect_err("out-of-range x index must error");
6548 assert!(err.contains("out of range"), "unexpected error: {err}");
6549 }
6550
6551 // ---------------------------------------------------------------
6552 // gh #492 — a constant `C<i>` body folds into the row bounds.
6553 //
6554 // `EQ_LIN` is `x0 + x1 = 1` with an empty `C0` (`n0`) and the row
6555 // bound in the `r` segment (`4 1`). Rewriting `C0` gives a family of
6556 // constant-body rows to fold.
6557 // ---------------------------------------------------------------
6558
6559 /// Linearity is a *linearity* test, not "did a `C` segment touch this
6560 /// row". A row whose body is the bare constant `3` is affine.
6561 #[test]
6562 fn a_constant_row_body_folds_into_both_bounds_and_reads_linear() {
6563 // `x0 + x1 + 3 = 1` ⇔ `x0 + x1 = -2`.
6564 let nl = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
6565 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6566 let p = parse_nl_text(&nl).expect("parse");
6567
6568 assert!(
6569 p.con_nonlinear[0].is_trivially_zero(),
6570 "the constant body must be replaced by the identity zero, got {:?}",
6571 p.con_nonlinear[0]
6572 );
6573 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
6574 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
6575
6576 let mut lin = [Linearity::NonLinear];
6577 let mut t = NlTnlp::new(p);
6578 assert!(t.get_constraints_linearity(&mut lin));
6579 assert_eq!(lin[0], Linearity::Linear);
6580 }
6581
6582 /// The shift must be exact, not merely "linear now": the folded model
6583 /// and the hand-folded one must be the same problem, row body for row
6584 /// body and bound for bound. That is what keeps feasibility, the
6585 /// active set, and the duals unchanged.
6586 #[test]
6587 fn folding_a_row_constant_gives_the_hand_folded_problem() {
6588 // `x0 + x1 + 3 = 1` against `x0 + x1 = -2`, written directly.
6589 let offset = EQ_LIN.replace("C0\nn0\n", "C0\nn3\n");
6590 let folded = EQ_LIN.replace("r\n4 1\n", "r\n4 -2\n");
6591 assert_ne!(offset, EQ_LIN);
6592 assert_ne!(folded, EQ_LIN);
6593
6594 let a = parse_nl_text(&offset).expect("parse offset form");
6595 let b = parse_nl_text(&folded).expect("parse folded form");
6596 assert_eq!(a.g_l, b.g_l);
6597 assert_eq!(a.g_u, b.g_u);
6598 assert_eq!(a.con_linear, b.con_linear);
6599
6600 // And the row *values* agree pointwise, which is the property the
6601 // duals ride on: `g(x) - g_l` is the same residual either way.
6602 let mut ga = [0.0];
6603 let mut gb = [0.0];
6604 let x = [0.75, -1.25];
6605 assert!(NlTnlp::new(a).eval_g(&x, true, &mut ga));
6606 assert!(NlTnlp::new(b).eval_g(&x, true, &mut gb));
6607 assert!((ga[0] - gb[0]).abs() < 1e-12, "{ga:?} vs {gb:?}");
6608 }
6609
6610 /// The fold is by *evaluation*, not by syntax: `o0 n1 n2` is as
6611 /// constant as `n3`, and AMPL emits such trees when a expression
6612 /// collapses without being re-simplified.
6613 #[test]
6614 fn a_row_body_that_evaluates_to_a_constant_folds_too() {
6615 // `C0` = `1 + 2`.
6616 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no0\nn1\nn2\n");
6617 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6618 let p = parse_nl_text(&nl).expect("parse");
6619 assert!(p.con_nonlinear[0].is_trivially_zero());
6620 assert!((p.g_l[0] - (-2.0)).abs() < 1e-12, "g_l = {}", p.g_l[0]);
6621 assert!((p.g_u[0] - (-2.0)).abs() < 1e-12, "g_u = {}", p.g_u[0]);
6622 }
6623
6624 /// Bound presence is directional (gh #401): the ±1e19 sentinels mean
6625 /// "absent", not "a very large number". Shifting one turns it into a
6626 /// real bound and invents a constraint that is not in the model.
6627 ///
6628 /// The constants here are deliberately huge. An everyday `3` is
6629 /// absorbed by the sentinel's own ULP (2048 at 1e19), so a missing
6630 /// presence guard would go unnoticed at ordinary magnitudes and then
6631 /// bite on a model that scales its rows. The guard is what makes the
6632 /// sentinel untouchable at *any* magnitude.
6633 #[test]
6634 fn folding_a_row_constant_leaves_the_absent_bound_sentinel_alone() {
6635 // `x0 + x1 - 1e18 <= 1`: upper-bounded row (`r` kind 1), no lower
6636 // bound. Shifting the lower sentinel would leave `-9e18`, a real
6637 // bound, so the row would gain a floor the model never stated.
6638 let nl = EQ_LIN
6639 .replace("C0\nn0\n", "C0\nn-1e18\n")
6640 .replace("r\n4 1\n", "r\n1 1\n");
6641 let p = parse_nl_text(&nl).expect("parse");
6642 assert!((p.g_u[0] - 1.0e18).abs() < 1024.0, "g_u = {}", p.g_u[0]);
6643 assert!(
6644 !lower_bound_present(p.g_l[0]),
6645 "the absent-lower sentinel became a real bound: {}",
6646 p.g_l[0]
6647 );
6648
6649 // The mirror case: a positive constant on a lower-bounded row is
6650 // the one that would pull the *upper* sentinel below 1e19.
6651 let nl = EQ_LIN
6652 .replace("C0\nn0\n", "C0\nn1e18\n")
6653 .replace("r\n4 1\n", "r\n2 1\n");
6654 let p = parse_nl_text(&nl).expect("parse");
6655 assert!((p.g_l[0] + 1.0e18).abs() < 1024.0, "g_l = {}", p.g_l[0]);
6656 assert!(
6657 !upper_bound_present(p.g_u[0]),
6658 "the absent-upper sentinel became a real bound: {}",
6659 p.g_u[0]
6660 );
6661 }
6662
6663 /// A row body that mentions a variable is not a constant, however
6664 /// simple it looks. Folding it would delete the term.
6665 #[test]
6666 fn a_row_body_with_a_variable_is_not_folded() {
6667 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no5\nv0\nn2\n"); // x0²
6668 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6669 let p = parse_nl_text(&nl).expect("parse");
6670 assert!(
6671 !matches!(p.con_nonlinear[0].tree(), Some(Expr::Const(_))),
6672 "a row in x0 was folded away: {:?}",
6673 p.con_nonlinear[0]
6674 );
6675 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
6676 assert!((p.g_u[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_u[0]);
6677 }
6678
6679 /// A variable-free body whose value is not finite is left in place. It
6680 /// makes the row infeasible (or ill-posed) and that is the solver's
6681 /// verdict to report; pushing a NaN into `g_l`/`g_u` would instead
6682 /// corrupt the bound pair and take every downstream presence test with
6683 /// it.
6684 #[test]
6685 fn a_non_finite_constant_row_body_is_not_folded() {
6686 // `C0` = `log(-1)` = NaN.
6687 let nl = EQ_LIN.replace("C0\nn0\n", "C0\no43\nn-1\n");
6688 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6689 let p = parse_nl_text(&nl).expect("parse");
6690 assert!(
6691 !p.con_nonlinear[0].is_trivially_zero(),
6692 "a NaN body was folded into the bounds"
6693 );
6694 assert!(p.g_l[0].is_finite() && p.g_u[0].is_finite());
6695 assert!((p.g_l[0] - 1.0).abs() < 1e-12);
6696 }
6697
6698 /// An imported-function call is not a parse-time constant even with
6699 /// constant arguments — it is resolved to a shared library much later
6700 /// (`nl_external::ExternalResolver`), and `eval_expr` panics on it
6701 /// rather than guess. The fold must decline before it evaluates.
6702 #[test]
6703 fn a_constant_argument_funcall_row_body_is_not_folded() {
6704 // Declare one imported function and call it with a literal.
6705 let nl = EQ_LIN.replace("C0\nn0\n", "F0 1 1 myfunc\nC0\nf0 1\nn2.0\n");
6706 assert_ne!(nl, EQ_LIN, "fixture substitution must apply");
6707 let p = parse_nl_text(&nl).expect("parse");
6708 assert!(
6709 matches!(p.con_nonlinear[0].tree(), Some(Expr::Funcall { .. })),
6710 "expected the funcall to survive the fold, got {:?}",
6711 p.con_nonlinear[0]
6712 );
6713 assert!((p.g_l[0] - 1.0).abs() < 1e-12, "bounds moved: {}", p.g_l[0]);
6714 }
6715
6716 #[test]
6717 fn k_segment_nonstandard_count_is_parse_error_at_source() {
6718 // Code review L35: the `k` (Jacobian column-count) segment header
6719 // declares how many count lines follow — `k<count>` — and the
6720 // standard value is n-1. The parser used to *assume* n-1 and ignore
6721 // the header, so a file declaring a different count read the wrong
6722 // number of data lines, desynced the segment stream, and failed far
6723 // downstream with a confusing error (or silently mis-parsed). With
6724 // the declared count now read and validated, a nonstandard count is
6725 // a clear parse error at its source. Here EQ_LIN has n=2 (expected
6726 // count 1); rewrite its `k1` + one count line to `k0`.
6727 let bad = EQ_LIN.replace("k1\n2\n", "k0\n");
6728 assert_ne!(bad, EQ_LIN, "fixture substitution must apply");
6729 let err = parse_nl_text(&bad).expect_err("nonstandard k count must error");
6730 assert!(
6731 err.contains("k-segment declares"),
6732 "expected a clear k-segment count error, got: {err}"
6733 );
6734 }
6735
6736 #[test]
6737 fn get_starting_point_returns_nl_initial_duals() {
6738 // Code review 2026-06 item M19: the `.nl` `d` segment supplies
6739 // initial constraint multipliers. They are parsed into `lambda0`,
6740 // but `get_starting_point` previously ignored them — so a
6741 // `warm_start_init_point yes` solve silently began from zero duals.
6742 // `get_starting_point` must hand the parsed duals back when the
6743 // engine requests them (`init_lambda`), and leave the buffer
6744 // untouched when it does not.
6745 let nl = format!("{EQ_LIN}\nd1\n0 2.5\n");
6746 let p = parse_nl_text(&nl).expect("parse");
6747 assert_eq!(p.lambda0, vec![2.5], "the `d` segment fills lambda0");
6748
6749 let mut t = NlTnlp::new(p);
6750 let info = t.get_nlp_info().unwrap();
6751 let (n, m) = (info.n as usize, info.m as usize);
6752
6753 // Warm-start request: init_lambda = true → the parsed `.nl` duals
6754 // must be returned (pre-fix this stayed zero).
6755 let mut x = vec![0.0; n];
6756 let mut z_l = vec![0.0; n];
6757 let mut z_u = vec![0.0; n];
6758 let mut lambda = vec![0.0; m];
6759 assert!(t.get_starting_point(StartingPoint {
6760 init_x: true,
6761 x: &mut x,
6762 init_z: false,
6763 z_l: &mut z_l,
6764 z_u: &mut z_u,
6765 init_lambda: true,
6766 lambda: &mut lambda,
6767 }));
6768 assert_eq!(
6769 lambda,
6770 vec![2.5],
6771 "a warm start must use the `.nl` initial duals, not zero"
6772 );
6773
6774 // No warm-start request: the multiplier buffer is left alone (the
6775 // engine owns its default), so honoring the flag does not clobber it.
6776 let mut lambda_untouched = vec![7.0; m];
6777 assert!(t.get_starting_point(StartingPoint {
6778 init_x: true,
6779 x: &mut x,
6780 init_z: false,
6781 z_l: &mut z_l,
6782 z_u: &mut z_u,
6783 init_lambda: false,
6784 lambda: &mut lambda_untouched,
6785 }));
6786 assert_eq!(
6787 lambda_untouched,
6788 vec![7.0],
6789 "without init_lambda the multiplier buffer must be untouched"
6790 );
6791 }
6792
6793 /// `.nl` text for `minimize sum_j (x_j - 1)^2 + x_0 * sum_j x_j`,
6794 /// unconstrained, `n` variables. The trailing product puts a nonzero
6795 /// in row 0 of *every* Hessian column, which is the shape that used
6796 /// to force the greedy coloring to hand out one color per variable.
6797 fn dense_row_objective_nl(n: usize) -> String {
6798 let mut s = String::new();
6799 s.push_str("g3 1 1 0\n");
6800 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
6801 s.push_str(" 0 1\n 0 0\n");
6802 s.push_str(&format!(" {n} {n} {n}\n"));
6803 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
6804 s.push_str(&format!(" 0 {n}\n"));
6805 s.push_str(" 0 0\n 0 0 0 0 0\n");
6806 // objective
6807 s.push_str("O0 0\n");
6808 s.push_str(&format!("o54\n{}\n", n + 1));
6809 for j in 0..n {
6810 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
6811 }
6812 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
6813 for j in 0..n {
6814 s.push_str(&format!("v{j}\n"));
6815 }
6816 // start, bounds, gradient
6817 s.push_str(&format!("x{n}\n"));
6818 for j in 0..n {
6819 s.push_str(&format!("{j} 0.5\n"));
6820 }
6821 s.push_str("b\n");
6822 for _ in 0..n {
6823 s.push_str("3\n");
6824 }
6825 s.push_str(&format!("G0 {n}\n"));
6826 for j in 0..n {
6827 s.push_str(&format!("{j} 0.0\n"));
6828 }
6829 s
6830 }
6831
6832 /// Same shape as [`dense_row_objective_nl`], but the coupling term
6833 /// carries per-variable weights: `sum_j (x_j - 1)^2 + x_0 * sum_j w_j
6834 /// x_j`, so `H[j, 0] == w_j`. Spreading `w` over `span` orders of
6835 /// magnitude makes the dense column ill-scaled — the case where
6836 /// reading its entries out of its own pass costs real digits.
6837 fn weighted_dense_row_objective_nl(n: usize, span: f64) -> String {
6838 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
6839 let mut s = String::new();
6840 s.push_str("g3 1 1 0\n");
6841 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
6842 s.push_str(" 0 1\n 0 0\n");
6843 s.push_str(&format!(" {n} {n} {n}\n"));
6844 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
6845 s.push_str(&format!(" 0 {n}\n"));
6846 s.push_str(" 0 0\n 0 0 0 0 0\n");
6847 s.push_str("O0 0\n");
6848 s.push_str(&format!("o54\n{}\n", n + 1));
6849 for j in 0..n {
6850 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
6851 }
6852 s.push_str(&format!("o2\nv0\no54\n{n}\n"));
6853 for j in 0..n {
6854 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
6855 }
6856 s.push_str(&format!("x{n}\n"));
6857 for j in 0..n {
6858 s.push_str(&format!("{j} 0.5\n"));
6859 }
6860 s.push_str("b\n");
6861 for _ in 0..n {
6862 s.push_str("3\n");
6863 }
6864 s.push_str(&format!("G0 {n}\n"));
6865 for j in 0..n {
6866 s.push_str(&format!("{j} 0.0\n"));
6867 }
6868 s
6869 }
6870
6871 /// Locate a model in the benchmark corpus, or `None` if the corpus is
6872 /// not on this machine.
6873 ///
6874 /// The corpus is ~2 GB and deliberately outside the checkout (see
6875 /// `POUNCE_BENCH_DATA`), so tests that need it have to degrade to a
6876 /// no-op rather than fail. That is a real limitation — a check that
6877 /// silently does nothing is how the gap below went unnoticed in the
6878 /// first place — so anything using this must also be covered by a
6879 /// synthetic case that always runs.
6880 fn bench_model(rel: &str) -> Option<std::path::PathBuf> {
6881 let root = std::env::var("POUNCE_BENCH_DATA").ok()?;
6882 let p = std::path::PathBuf::from(root).join(rel);
6883 p.is_file().then_some(p)
6884 }
6885
6886 /// The corpus check the dense-column optimization never got.
6887 ///
6888 /// `cho_parmest` is the model whose certificate the optimization cost,
6889 /// and it could not have caught it: the model is 4.3 MB and lives in
6890 /// the benchmark data set, not in the repository, so validating
6891 /// against the in-repo `.nl` fixtures said nothing about it. This test
6892 /// closes that by checking the corpus directly wherever the corpus
6893 /// exists, which is every machine and CI job that runs the benchmarks.
6894 ///
6895 /// The assertion is the one that matters and the one that was never
6896 /// made: whatever the guard leaves peeled must decode to the same
6897 /// Hessian as peeling nothing. Against the pre-fix code this fails —
6898 /// 48,931 of the 96,000 entries disagreed, to 5.75e-12 relative.
6899 #[test]
6900 fn cho_parmest_decodes_to_its_unpeeled_reference() {
6901 let Some(path) = bench_model("cho/nl_export_results/cho_parmest.nl") else {
6902 eprintln!("POUNCE_BENCH_DATA/cho not present — skipping corpus check");
6903 return;
6904 };
6905 let p = read_nl_file(&path).expect("read cho_parmest");
6906 let n = p.n;
6907 let mut t = NlTnlp::new(p);
6908 // The guard vetoes 7 of cho's 12 peeled columns, and putting those
6909 // seven dense rows back into the conflict graph costs the coloring
6910 // outright: it goes from 17 colors to 9010, and no column clears the
6911 // density threshold afterwards, so the model ends up fully unpeeled.
6912 // That is the price of the certificate on this model, and it is worth
6913 // knowing rather than assuming the other five survive.
6914 assert!(t.peeled_cols.is_empty());
6915
6916 let info = t.get_nlp_info().unwrap();
6917 let nnz = info.nnz_h_lag as usize;
6918 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
6919 assert!(t.eval_h(
6920 None,
6921 true,
6922 1.0,
6923 None,
6924 true,
6925 SparsityRequest::Structure {
6926 irow: &mut irow,
6927 jcol: &mut jcol
6928 }
6929 ));
6930 // Evaluate away from `x0` with non-uniform multipliers. The damage
6931 // is point-dependent, and `x0` is close to where it is least
6932 // visible: only 5 entries move there, against 48,931 here. Note the
6933 // guard's own probe runs at `x0` — it fires anyway because it tests
6934 // a bound on what a pass *could* lose, not the damage it happens to
6935 // commit at one point. That is the property that makes it robust,
6936 // and this asymmetry is worth keeping in front of anyone who
6937 // retunes it.
6938 let x: Vec<f64> = t
6939 .prob
6940 .x0
6941 .iter()
6942 .enumerate()
6943 .map(|(i, v)| v + 0.01 * (i % 7) as f64 + 0.001)
6944 .collect();
6945 let lambda: Vec<f64> = (0..t.prob.m).map(|i| 0.5 + 0.01 * (i % 5) as f64).collect();
6946
6947 let mut got = vec![0.0_f64; nnz];
6948 assert!(t.eval_h(
6949 Some(&x),
6950 true,
6951 1.0,
6952 Some(&lambda),
6953 true,
6954 SparsityRequest::Values { values: &mut got }
6955 ));
6956
6957 t.recolor(&vec![true; n]);
6958 assert!(t.peeled_cols.is_empty());
6959 let mut want = vec![0.0_f64; nnz];
6960 assert!(t.eval_h(
6961 Some(&x),
6962 true,
6963 1.0,
6964 Some(&lambda),
6965 true,
6966 SparsityRequest::Values { values: &mut want }
6967 ));
6968
6969 let scale = want.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
6970 let mut worst = 0.0_f64;
6971 let mut at = 0usize;
6972 for k in 0..nnz {
6973 let rel = (got[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
6974 if rel > worst {
6975 worst = rel;
6976 at = k;
6977 }
6978 }
6979 assert!(
6980 worst <= 1e-13,
6981 "H[{},{}] decoded {:e}, unpeeled reference {:e} — relative error \
6982 {worst:e} (||H||inf = {scale:e}). A peeled column is being read \
6983 out of a pass it cannot be read out of.",
6984 irow[at],
6985 jcol[at],
6986 got[at],
6987 want[at]
6988 );
6989
6990 // The check above passes trivially on correct code, because the
6991 // guard leaves cho fully unpeeled and so compares a configuration
6992 // against itself. It only has teeth against a regression. So prove
6993 // the guard is doing necessary work rather than assuming it: put
6994 // the peels back the way the pre-fix reader had them and confirm
6995 // the Hessian really does come apart.
6996 t.recolor(&vec![false; n]);
6997 assert!(
6998 !t.peeled_cols.is_empty(),
6999 "restoring the unvetoed coloring must peel again"
7000 );
7001 let mut unguarded = vec![0.0_f64; nnz];
7002 assert!(t.eval_h(
7003 Some(&x),
7004 true,
7005 1.0,
7006 Some(&lambda),
7007 true,
7008 SparsityRequest::Values {
7009 values: &mut unguarded
7010 }
7011 ));
7012 let mut bad = 0usize;
7013 let mut worst_unguarded = 0.0_f64;
7014 for k in 0..nnz {
7015 let rel = (unguarded[k] - want[k]).abs() / want[k].abs().max(f64::MIN_POSITIVE);
7016 if rel > 1e-13 {
7017 bad += 1;
7018 }
7019 worst_unguarded = worst_unguarded.max(rel);
7020 }
7021 // Two different counts get quoted about this model and they measure
7022 // different things: 48,931 of the 96,000 entries differ from the
7023 // uncompressed reference *at all*, down to the last bit, while 88
7024 // exceed 1e-13 relative. The second is the one worth asserting on.
7025 assert!(
7026 bad >= 50 && worst_unguarded > 1e-11,
7027 "peeling cho_parmest unguarded should damage the entries the guard \
7028 exists to protect (measured: 88 entries past 1e-13 relative, \
7029 worst 9.9e-11); got {bad} entries, worst {worst_unguarded:e}. If \
7030 this fires, the model or the corpus changed and the guard's \
7031 calibration should be re-derived rather than the bound relaxed."
7032 );
7033 eprintln!("unguarded peeling damages {bad}/{nnz} entries, worst {worst_unguarded:e}");
7034 }
7035
7036 /// Same weighted coupling as [`weighted_dense_row_objective_nl`], but
7037 /// the dense variable is `x_{n-1}` instead of `x_0`:
7038 /// `sum_j (x_j - 1)^2 + x_{n-1} * sum_{j < n-1} w_j x_j`.
7039 ///
7040 /// The index matters, and it is the whole reason this helper exists.
7041 /// With the dense variable at 0 every coupling entry is stored as
7042 /// `(i, 0)` — the *column* is the peeled one — and the decode reads it
7043 /// out of column 0's own pass whether or not peeling is on, so the two
7044 /// paths are bit-identical and no test built on that shape can tell
7045 /// them apart. Putting the dense variable last stores them as
7046 /// `(n-1, j)`, where the *row* is the peeled column: peeled, they come
7047 /// back from column `n-1`'s pass by symmetry, carrying that pass's
7048 /// roundoff floor; unpeeled, they come back from column `j`'s own pass.
7049 /// That is the category every one of `cho_parmest`'s 48,931 damaged
7050 /// entries fell into.
7051 fn weighted_dense_last_col_nl(n: usize, span: f64) -> String {
7052 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 2) as f64);
7053 let d = n - 1;
7054 let mut s = String::new();
7055 s.push_str("g3 1 1 0\n");
7056 s.push_str(&format!(" {n} 0 1 0 0 0\n"));
7057 s.push_str(" 0 1\n 0 0\n");
7058 s.push_str(&format!(" {n} {n} {n}\n"));
7059 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
7060 s.push_str(&format!(" 0 {n}\n"));
7061 s.push_str(" 0 0\n 0 0 0 0 0\n");
7062 s.push_str("O0 0\n");
7063 s.push_str(&format!("o54\n{}\n", n + 1));
7064 for j in 0..n {
7065 s.push_str(&format!("o5\no1\nv{j}\nn1.0\nn2\n"));
7066 }
7067 s.push_str(&format!("o2\nv{d}\no54\n{}\n", n - 1));
7068 for j in 0..d {
7069 s.push_str(&format!("o2\nn{:.17e}\nv{j}\n", w(j)));
7070 }
7071 s.push_str(&format!("x{n}\n"));
7072 for j in 0..n {
7073 s.push_str(&format!("{j} 0.5\n"));
7074 }
7075 s.push_str("b\n");
7076 for _ in 0..n {
7077 s.push_str("3\n");
7078 }
7079 s.push_str(&format!("G0 {n}\n"));
7080 for j in 0..n {
7081 s.push_str(&format!("{j} 0.0\n"));
7082 }
7083 s
7084 }
7085
7086 /// A well-scaled dense column is still peeled, and the entries read
7087 /// out of its pass are exact — the property the peel guard must not
7088 /// cost us.
7089 #[test]
7090 fn a_well_scaled_dense_column_is_still_peeled() {
7091 let n = 200;
7092 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, 0.0)).expect("parse");
7093 let t = NlTnlp::new(p);
7094 assert_eq!(
7095 t.peeled_cols,
7096 vec![0],
7097 "a dense column that costs no accuracy must stay peeled"
7098 );
7099 assert!(
7100 t.seeds.len() <= 4,
7101 "peeling should keep the color count at O(1), got {}",
7102 t.seeds.len()
7103 );
7104 }
7105
7106 /// An ill-scaled dense column must be un-peeled and colored the
7107 /// ordinary way, so its small entries come back to full precision.
7108 ///
7109 /// This test covers the *guard*, not the damage. It asserts that a
7110 /// column spanning 12 orders is un-peeled and that its entries are then
7111 /// exact — and it is worth being precise that it does not, and cannot,
7112 /// show what peeling would have cost, because on this model peeling
7113 /// costs nothing: force the peel through and every entry still comes
7114 /// back bit-identical to its analytic weight. The Hessian here is
7115 /// constant and each entry is a single product, so there is no
7116 /// accumulation for a large-magnitude pass to pollute.
7117 ///
7118 /// Reproducing the actual digit loss takes a model whose entries are
7119 /// summed through shared intermediates, which is why the demonstration
7120 /// lives in `cho_parmest_decodes_to_its_unpeeled_reference` against the
7121 /// real model rather than a synthetic one.
7122 #[test]
7123 fn an_ill_scaled_dense_column_is_not_peeled_and_stays_exact() {
7124 let n = 200;
7125 let span = 12.0;
7126 let w = |j: usize| 10_f64.powf(span / 2.0 - span * j as f64 / (n - 1) as f64);
7127 let p = parse_nl_text(&weighted_dense_row_objective_nl(n, span)).expect("parse");
7128 let mut t = NlTnlp::new(p);
7129
7130 assert!(
7131 t.peeled_cols.is_empty(),
7132 "a dense column spanning {span} orders must not be peeled; got {:?}",
7133 t.peeled_cols
7134 );
7135
7136 let info = t.get_nlp_info().unwrap();
7137 let nnz = info.nnz_h_lag as usize;
7138 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
7139 assert!(t.eval_h(
7140 None,
7141 true,
7142 1.0,
7143 None,
7144 true,
7145 SparsityRequest::Structure {
7146 irow: &mut irow,
7147 jcol: &mut jcol
7148 }
7149 ));
7150 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
7151 let mut vals = vec![0.0_f64; nnz];
7152 assert!(t.eval_h(
7153 Some(&x),
7154 true,
7155 1.0,
7156 None,
7157 true,
7158 SparsityRequest::Values { values: &mut vals }
7159 ));
7160
7161 // Every coupling entry must be its weight to full relative
7162 // precision. Peeled, the smallest of them came back with a
7163 // relative error near 1e-4.
7164 let mut checked = 0;
7165 for k in 0..nnz {
7166 let (i, j) = (irow[k] as usize, jcol[k] as usize);
7167 if i == j {
7168 continue;
7169 }
7170 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
7171 checked += 1;
7172 let want = w(i);
7173 assert!(
7174 (vals[k] - want).abs() <= 1e-13 * want,
7175 "H[{i},0] = {:e}, want {want:e} (relative error {:e})",
7176 vals[k],
7177 (vals[k] - want).abs() / want
7178 );
7179 }
7180 assert_eq!(checked, n - 1);
7181 }
7182
7183 /// Whatever survives the peel guard must decode to the same Hessian
7184 /// that not peeling at all produces — every entry, not just the
7185 /// objective.
7186 ///
7187 /// This exists because the original dense-column optimization was
7188 /// validated by running the repository's `.nl` fixtures and comparing
7189 /// objective value and exit status, which was doubly blind: not one of
7190 /// the 60 fixtures has a Hessian row dense enough to peel anything, so
7191 /// the decode path under test never executed, and even had it executed,
7192 /// the defect cost digits in the multipliers while leaving the
7193 /// objective intact. So the instrument has to be the assembled Hessian
7194 /// and the input has to actually peel — hence `peeled_any` below, which
7195 /// fails if the sweep ever goes vacuous the way the fixture suite
7196 /// silently did.
7197 ///
7198 /// What this catches is a *decode* fault: an entry recovered from the
7199 /// wrong pass, which is wrong by O(1). It would not have caught the
7200 /// `cho_parmest` stall, because these synthetic models lose no
7201 /// precision under peeling at all (see
7202 /// `an_ill_scaled_dense_column_is_not_peeled_and_stays_exact`). The
7203 /// precision half is covered against the real model in
7204 /// `cho_parmest_decodes_to_its_unpeeled_reference`.
7205 #[test]
7206 fn a_peeled_decode_matches_an_unpeeled_reference() {
7207 // `last = true` puts the dense variable at `n-1`, so the coupling
7208 // entries are stored with the peeled column as their *row* — the
7209 // only shape in which peeling and not peeling read an entry out of
7210 // different passes, and so the only shape that can detect a
7211 // difference at all. See `weighted_dense_last_col_nl`.
7212 let cases = [
7213 (200, 0.0, false),
7214 (200, 2.0, false),
7215 (200, 0.0, true),
7216 (200, 1.0, true),
7217 (200, 2.0, true),
7218 (400, 3.0, true),
7219 (600, 0.0, true),
7220 ];
7221 let mut peeled_any = false;
7222 let mut worst = 0.0_f64;
7223
7224 for &(n, span, last) in &cases {
7225 let text = if last {
7226 weighted_dense_last_col_nl(n, span)
7227 } else {
7228 weighted_dense_row_objective_nl(n, span)
7229 };
7230 let p = parse_nl_text(&text).expect("parse");
7231 let mut t = NlTnlp::new(p);
7232 let peeled = t.peeled_cols.clone();
7233 peeled_any |= !peeled.is_empty();
7234
7235 let info = t.get_nlp_info().unwrap();
7236 let nnz = info.nnz_h_lag as usize;
7237 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
7238 assert!(t.eval_h(
7239 None,
7240 true,
7241 1.0,
7242 None,
7243 true,
7244 SparsityRequest::Structure {
7245 irow: &mut irow,
7246 jcol: &mut jcol
7247 }
7248 ));
7249 let x: Vec<f64> = (0..n).map(|j| 0.25 + 0.05 * (j % 13) as f64).collect();
7250
7251 let mut got = vec![0.0_f64; nnz];
7252 assert!(t.eval_h(
7253 Some(&x),
7254 true,
7255 1.0,
7256 None,
7257 true,
7258 SparsityRequest::Values { values: &mut got }
7259 ));
7260
7261 // Same object, same tapes, same point — only the coloring
7262 // differs, so any disagreement is the decode path.
7263 t.recolor(&vec![true; n]);
7264 assert!(
7265 t.peeled_cols.is_empty(),
7266 "a fully vetoed model must peel nothing"
7267 );
7268 let mut want = vec![0.0_f64; nnz];
7269 assert!(t.eval_h(
7270 Some(&x),
7271 true,
7272 1.0,
7273 None,
7274 true,
7275 SparsityRequest::Values { values: &mut want }
7276 ));
7277
7278 for k in 0..nnz {
7279 let scale = want[k].abs().max(f64::MIN_POSITIVE);
7280 let rel = (got[k] - want[k]).abs() / scale;
7281 worst = worst.max(rel);
7282 assert!(
7283 rel <= 1e-13,
7284 "n={n} span={span} last={last} peeled={peeled:?}: H[{},{}] decoded {:e}, \
7285 unpeeled reference {:e} (relative error {rel:e})",
7286 irow[k],
7287 jcol[k],
7288 got[k],
7289 want[k]
7290 );
7291 }
7292 }
7293
7294 assert!(
7295 peeled_any,
7296 "no case peeled anything, so this test proved nothing about the \
7297 decode path — the exact way the fixture suite missed the bug"
7298 );
7299 assert!(worst < 1e-13, "worst relative disagreement {worst:e}");
7300 }
7301
7302 /// `peel_veto` bars a column from the peel set, and the row it puts
7303 /// back into the conflict structure costs the colors it used to save.
7304 #[test]
7305 fn a_vetoed_column_is_colored_the_ordinary_way() {
7306 let n = 300;
7307 // One dense row plus a diagonal: column 0 touches every row.
7308 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7309 pairs.extend((1..n).map(|i| (i, 0)));
7310 pairs.sort_unstable();
7311
7312 let (_, colors_peeled, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7313 assert!(peeled[0], "the dense column should peel by default");
7314 assert!(
7315 colors_peeled <= 4,
7316 "peeling should collapse the count, got {colors_peeled}"
7317 );
7318
7319 let mut veto = vec![false; n];
7320 veto[0] = true;
7321 let (_, colors_vetoed, peeled) = greedy_hessian_coloring(n, &pairs, &veto);
7322 assert!(!peeled[0], "a vetoed column must not be peeled");
7323 assert!(
7324 colors_vetoed > colors_peeled,
7325 "un-peeling restores row 0's conflicts, so colors must rise: \
7326 {colors_vetoed} vs {colors_peeled}"
7327 );
7328 }
7329
7330 /// A single dense Hessian row must not blow the coloring up to one
7331 /// color per variable. It used to: every column shares row 0, so no
7332 /// two columns could be colored alike, and `seeds` / `compressed`
7333 /// — both `n_colors × n` dense — became O(n²) memory and O(n²) work
7334 /// per `eval_h` on a Hessian holding only ~2n nonzeros.
7335 #[test]
7336 fn a_dense_hessian_row_does_not_explode_the_coloring() {
7337 let n = 200;
7338 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
7339 let t = NlTnlp::new(p);
7340 // 2n - 1 entries: the diagonal (j, j) for every j, plus (j, 0)
7341 // for j >= 1 from the coupling term.
7342 assert_eq!(t.h_irow.len(), 2 * n - 1);
7343 assert!(
7344 t.seeds.len() <= 4,
7345 "one dense row should cost one extra color, not n; got {} colors for n={n}",
7346 t.seeds.len()
7347 );
7348 assert_eq!(t.seeds.len(), t.compressed.len());
7349 }
7350
7351 /// Peeling changes *which* directional product recovers an entry, so
7352 /// the recovered Hessian must still be exactly right — including the
7353 /// entries read out of a peeled column's pass by symmetry.
7354 #[test]
7355 fn peeled_dense_column_still_recovers_the_exact_hessian() {
7356 let n = 200;
7357 let p = parse_nl_text(&dense_row_objective_nl(n)).expect("parse");
7358 let mut t = NlTnlp::new(p);
7359 let info = t.get_nlp_info().unwrap();
7360 let nnz = info.nnz_h_lag as usize;
7361
7362 let mut irow = vec![0_i32; nnz];
7363 let mut jcol = vec![0_i32; nnz];
7364 assert!(t.eval_h(
7365 None,
7366 true,
7367 1.0,
7368 None,
7369 true,
7370 SparsityRequest::Structure {
7371 irow: &mut irow,
7372 jcol: &mut jcol
7373 }
7374 ));
7375
7376 // f = sum_j (x_j - 1)^2 + x_0 * sum_j x_j
7377 // H[0,0] = 2 + 2 = 4; H[j,j] = 2 (j >= 1); H[j,0] = 1 (j >= 1).
7378 let x: Vec<f64> = (0..n).map(|j| 0.1 * j as f64).collect();
7379 let obj_factor = 2.5;
7380 let mut vals = vec![0.0_f64; nnz];
7381 assert!(t.eval_h(
7382 Some(&x),
7383 true,
7384 obj_factor,
7385 None,
7386 true,
7387 SparsityRequest::Values { values: &mut vals }
7388 ));
7389
7390 let mut seen_diag = 0;
7391 let mut seen_coupling = 0;
7392 for k in 0..nnz {
7393 let (i, j) = (irow[k] as usize, jcol[k] as usize);
7394 let want = if i == 0 && j == 0 {
7395 4.0
7396 } else if i == j {
7397 seen_diag += 1;
7398 2.0
7399 } else {
7400 assert_eq!(j, 0, "unexpected off-diagonal ({i}, {j})");
7401 seen_coupling += 1;
7402 1.0
7403 } * obj_factor;
7404 assert!(
7405 (vals[k] - want).abs() < 1e-12,
7406 "H[{i},{j}] = {}, want {want}",
7407 vals[k]
7408 );
7409 }
7410 assert_eq!(seen_diag, n - 1);
7411 assert_eq!(seen_coupling, n - 1);
7412 }
7413
7414 /// The peeling threshold must leave ordinary sparse models on
7415 /// exactly the coloring they had before: a banded Hessian colors by
7416 /// its bandwidth, with nothing peeled.
7417 #[test]
7418 fn a_sparse_hessian_is_colored_by_its_bandwidth_not_peeled() {
7419 let n = 400;
7420 let pairs: Vec<(usize, usize)> = (0..n)
7421 .flat_map(|j| {
7422 let mut v = vec![(j, j)];
7423 if j + 1 < n {
7424 v.push((j + 1, j));
7425 }
7426 v
7427 })
7428 .collect();
7429 let (var_color, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7430 assert!(!peeled.iter().any(|&p| p), "nothing in a band is dense");
7431 assert!(
7432 n_colors <= 3,
7433 "a tridiagonal Hessian needs a handful of colors, got {n_colors}"
7434 );
7435 assert!(var_color.iter().all(|&c| c != u32::MAX));
7436 }
7437
7438 /// Build a Hessian of `blocks` disjoint dense `size`x`size` blocks
7439 /// scattered through an otherwise diagonal `n`-variable problem.
7440 /// A plain coloring needs exactly `size` colors no matter how many
7441 /// blocks there are, because the blocks share no rows.
7442 fn disjoint_blocks(n: usize, blocks: usize, size: usize) -> Vec<(usize, usize)> {
7443 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7444 let stride = n / blocks;
7445 for b in 0..blocks {
7446 let base = b * stride;
7447 for i in 0..size {
7448 for j in 0..=i {
7449 if i != j {
7450 pairs.push((base + i, base + j));
7451 }
7452 }
7453 }
7454 }
7455 pairs
7456 }
7457
7458 /// Many medium-degree columns must not be peeled.
7459 ///
7460 /// Regression: selecting candidates by "degree > 16x average" and then
7461 /// *truncating* the list to `MAX_PEELED_COLS` is not a damage bound.
7462 /// The columns that miss the cut stay in the conflict structure, so the
7463 /// base color count is untouched and the singleton colors are pure
7464 /// addition — these two patterns colored to 306 and 290 against a plain
7465 /// walk's 50 and 34.
7466 #[test]
7467 fn thousands_of_medium_degree_cols_are_not_peeled() {
7468 for (n, blocks, size) in [(200_000, 100, 50), (200_000, 300, 34)] {
7469 let pairs = disjoint_blocks(n, blocks, size);
7470 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7471 let n_peeled = peeled.iter().filter(|&&p| p).count();
7472 assert_eq!(
7473 n_peeled, 0,
7474 "degree-{size} columns do not pay for a singleton color \
7475 (n={n}, blocks={blocks}), yet {n_peeled} were peeled"
7476 );
7477 assert!(
7478 n_colors <= size + 1,
7479 "disjoint {size}x{size} blocks color by block size regardless \
7480 of block count; got {n_colors} for n={n}, blocks={blocks}"
7481 );
7482 }
7483 }
7484
7485 /// The pay-for-itself rule must not throw away the case peeling exists
7486 /// for: a handful of genuinely dense rows still get peeled, and still
7487 /// collapse the coloring.
7488 #[test]
7489 fn a_few_truly_dense_rows_are_still_peeled() {
7490 let n = 5_000;
7491 let dense_rows = 4;
7492 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7493 for d in 0..dense_rows {
7494 for j in 0..n {
7495 if j != d {
7496 pairs.push((j.max(d), j.min(d)));
7497 }
7498 }
7499 }
7500 let (_, n_colors, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7501 let n_peeled = peeled.iter().filter(|&&p| p).count();
7502 assert_eq!(n_peeled, dense_rows, "every full row should peel");
7503 assert!(
7504 n_colors <= dense_rows + 2,
7505 "peeling {dense_rows} full rows should leave a diagonal remainder, \
7506 got {n_colors} colors"
7507 );
7508 }
7509
7510 /// The cap still binds, and when it does the kept columns are the
7511 /// highest-degree ones.
7512 #[test]
7513 fn peeling_is_capped_and_keeps_the_worst_offenders() {
7514 let n = 20_000;
7515 let dense_rows = 400;
7516 let mut pairs: Vec<(usize, usize)> = (0..n).map(|j| (j, j)).collect();
7517 for d in 0..dense_rows {
7518 // Row d touches the first (n - d) columns, so degree strictly
7519 // decreases with d and the ordering is unambiguous.
7520 for j in 0..(n - d) {
7521 if j != d {
7522 pairs.push((j.max(d), j.min(d)));
7523 }
7524 }
7525 }
7526 let (_, _, peeled) = greedy_hessian_coloring(n, &pairs, &vec![false; n]);
7527 let n_peeled = peeled.iter().filter(|&&p| p).count();
7528 assert_eq!(n_peeled, MAX_PEELED_COLS, "cap binds at {MAX_PEELED_COLS}");
7529 assert!(
7530 (0..MAX_PEELED_COLS).all(|d| peeled[d]),
7531 "the {MAX_PEELED_COLS} densest rows are the ones kept"
7532 );
7533 }
7534
7535 /// Header line 0 is `g<count> <opt0> ...`; the option words are the
7536 /// model's own and a solver echoes them into the `.sol` `Options`
7537 /// block. `EQ_LIN`'s header is `g3 0 1 0`, so three words follow.
7538 #[test]
7539 fn header_option_words_are_kept_verbatim() {
7540 let p = parse_nl_text(EQ_LIN).expect("parse");
7541 assert_eq!(p.ampl_options, vec![0, 1, 0]);
7542 }
7543
7544 /// A count that does not match the words present must not be
7545 /// guessed at — the writer falls back rather than emit a wrong block.
7546 #[test]
7547 fn a_truncated_option_list_is_dropped_not_padded() {
7548 let text = EQ_LIN.replacen("g3 0 1 0", "g9 0 1 0", 1);
7549 let p = parse_nl_text(&text).expect("parse");
7550 assert!(
7551 p.ampl_options.is_empty(),
7552 "9 declared but 3 present: {:?}",
7553 p.ampl_options
7554 );
7555 }
7556
7557 #[test]
7558 fn constrained_tnlp_eval_g_jac_h() {
7559 let p = parse_nl_text(EQ_LIN).expect("parse");
7560 let mut t = NlTnlp::new(p);
7561 let info = t.get_nlp_info().unwrap();
7562 assert_eq!(info.m, 1);
7563 assert_eq!(info.nnz_jac_g, 2);
7564
7565 // g(0.3, 0.4) = 0.3 + 0.4 = 0.7
7566 let mut g = [0.0_f64; 1];
7567 assert!(t.eval_g(&[0.3, 0.4], true, &mut g));
7568 assert!((g[0] - 0.7).abs() < 1e-12);
7569
7570 // Jacobian structure: row 0, cols [0, 1].
7571 let mut irow = [0_i32; 2];
7572 let mut jcol = [0_i32; 2];
7573 assert!(t.eval_jac_g(
7574 None,
7575 true,
7576 SparsityRequest::Structure {
7577 irow: &mut irow,
7578 jcol: &mut jcol
7579 }
7580 ));
7581 assert_eq!(irow, [0, 0]);
7582 assert_eq!(jcol, [0, 1]);
7583
7584 // Jacobian values: both 1.0.
7585 let mut vals = [0.0_f64; 2];
7586 assert!(t.eval_jac_g(
7587 Some(&[0.3, 0.4]),
7588 true,
7589 SparsityRequest::Values { values: &mut vals }
7590 ));
7591 assert!((vals[0] - 1.0).abs() < 1e-12);
7592 assert!((vals[1] - 1.0).abs() < 1e-12);
7593
7594 // Hessian of L = (x0^2 + x1^2) + λ*(x0 + x1 - 1) is diag(2,2);
7595 // λ contributes nothing because the constraint is linear, and
7596 // x0^2 + x1^2 is separable so there's no (1,0) entry in the
7597 // structural sparsity. nnz_h_lag = 2: (0,0) and (1,1).
7598 assert_eq!(info.nnz_h_lag, 2);
7599 let mut hirow = [0_i32; 2];
7600 let mut hjcol = [0_i32; 2];
7601 assert!(t.eval_h(
7602 None,
7603 true,
7604 1.0,
7605 None,
7606 true,
7607 SparsityRequest::Structure {
7608 irow: &mut hirow,
7609 jcol: &mut hjcol
7610 }
7611 ));
7612 assert_eq!(hirow, [0, 1]);
7613 assert_eq!(hjcol, [0, 1]);
7614 let mut hvals = [0.0_f64; 2];
7615 assert!(t.eval_h(
7616 Some(&[0.3, 0.4]),
7617 true,
7618 1.0,
7619 Some(&[0.5]),
7620 true,
7621 SparsityRequest::Values { values: &mut hvals }
7622 ));
7623 assert!((hvals[0] - 2.0).abs() < 1e-12);
7624 assert!((hvals[1] - 2.0).abs() < 1e-12);
7625 }
7626
7627 /// `min (x0 + x1)^2 + (x0 + x1)` with the shared sum `(x0 + x1)`
7628 /// encoded as common-subexpression `V2`. Header line 10 declares
7629 /// one obj-only CSE; expression tree references `v2` twice.
7630 const CSE_OBJ: &str = "g3 0 1 0
76312 0 1 0 0
76320 1
76330 0
76340 2 0
76350 0 0 1
76360 0 0 0 0
76370 0
76380 0
76390 1 0 0 0
7640V2 0 0
7641o0
7642v0
7643v1
7644O0 0
7645o0
7646o5
7647v2
7648n2
7649v2
7650b
76513
76523
7653";
7654
7655 #[test]
7656 fn parses_v_segment_cse() {
7657 let p = parse_nl_text(CSE_OBJ).expect("parse");
7658 assert_eq!(p.n, 2);
7659 // f(1,2) = 9 + 3 = 12
7660 let f = eval_expr(&p.obj_expr(), &[1.0, 2.0]);
7661 assert!((f - 12.0).abs() < 1e-12, "got {f}");
7662 // d/dx0 = 2*(x0+x1) + 1 = 7 at (1,2). Same for x1.
7663 let mut g = [0.0_f64; 2];
7664 grad_expr(&p.obj_expr(), &[1.0, 2.0], 1.0, &mut g);
7665 assert!((g[0] - 7.0).abs() < 1e-12, "g[0]={}", g[0]);
7666 assert!((g[1] - 7.0).abs() < 1e-12, "g[1]={}", g[1]);
7667 // collect_vars reaches into the CSE body and finds {0, 1}.
7668 let mut vs = BTreeSet::new();
7669 p.obj_nonlinear.collect_vars(&mut vs);
7670 assert_eq!(vs.into_iter().collect::<Vec<_>>(), vec![0, 1]);
7671 }
7672
7673 /// `min (x0 - 1)^2` with three suffix segments attached: an
7674 /// integer constraint-suffix (target=1, kind=1), an integer var-
7675 /// suffix (target=0, kind=0), and a real var-suffix (target=0,
7676 /// kind=4). The .nl format is `S<kind> <nentries> <name>` then
7677 /// `<idx> <value>` lines.
7678 const WITH_SUFFIXES: &str = "g3 0 1 0
76791 0 1 0 0
76800 1
76810 0
76820 1 0
76830 0 0 1
76840 0 0 0 0
76850 0
76860 0
76870 0 0 0 0
7688O0 0
7689o5
7690o1
7691v0
7692n1
7693n2
7694b
76953
7696S0 1 sens_state_1
76970 7
7698S4 1 sens_state_value_1
76990 4.5
7700";
7701
7702 #[test]
7703 fn parses_var_int_and_var_real_suffixes() {
7704 let p = parse_nl_text(WITH_SUFFIXES).expect("parse");
7705 // Integer var-suffix: dense length 1, slot 0 = 7.
7706 let v = p.suffixes.var_int.get("sens_state_1").expect("var_int");
7707 assert_eq!(v.as_slice(), &[7]);
7708 // Real var-suffix: dense length 1, slot 0 = 4.5.
7709 let r = p
7710 .suffixes
7711 .var_real
7712 .get("sens_state_value_1")
7713 .expect("var_real");
7714 assert_eq!(r.len(), 1);
7715 assert!((r[0] - 4.5).abs() < 1e-12);
7716 // Other suffix slots stay empty.
7717 assert!(p.suffixes.con_int.is_empty());
7718 assert!(p.suffixes.con_real.is_empty());
7719 }
7720
7721 /// Two-variable + two-constraint problem with a constraint-level
7722 /// integer suffix (kind=1). Sparse entries scatter to dense length 2.
7723 const WITH_CON_SUFFIX: &str = "g3 0 1 0
77242 2 1 0 0
77250 0
77260 0
77270 2 0
77280 0 0 1
77290 0 0 0 0
77304 0
77310 0
77320 0 0 0 0 0
7733C0
7734n0
7735C1
7736n0
7737O0 0
7738n0
7739r
77404 0.0
77414 0.0
7742b
77433
77443
7745k1
77460
7747J0 2
77480 1
77491 1
7750J1 2
77510 1
77521 -1
7753S1 2 sens_init_constr
77540 1
77551 2
7756";
7757
7758 #[test]
7759 fn parses_con_int_suffix() {
7760 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
7761 let s = p.suffixes.con_int.get("sens_init_constr").expect("con_int");
7762 // Sparse {0:1, 1:2} → dense [1, 2] at length m=2.
7763 assert_eq!(s.as_slice(), &[1, 2]);
7764 }
7765
7766 // ---- gh#785: a truncated file is rejected, not silently defaulted ----
7767 //
7768 // `WITH_CON_SUFFIX` is a complete, well-formed file, so cutting it at a
7769 // segment boundary is exactly the failure the issue reports: an
7770 // interrupted write. Each cut loses a different first segment and each
7771 // is caught by a different check, so all three are asserted — and the
7772 // untruncated text parsing cleanly (`parses_con_int_suffix` above) is
7773 // what keeps these from passing against a parser that rejects
7774 // everything.
7775
7776 /// Everything from `at` (a segment header line) onward is gone.
7777 fn truncate_before(txt: &str, at: &str) -> String {
7778 let cut = txt
7779 .find(at)
7780 .unwrap_or_else(|| panic!("fixture has no {at:?} segment"));
7781 txt[..cut].to_string()
7782 }
7783
7784 #[test]
7785 fn truncation_before_the_row_bounds_is_a_parse_error() {
7786 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nr\n"))
7787 .expect_err("truncated file must not parse");
7788 assert!(
7789 err.contains("`r` (constraint-bounds) segment"),
7790 "error should name the missing segment: {err}"
7791 );
7792 }
7793
7794 #[test]
7795 fn truncation_before_the_variable_bounds_is_a_parse_error() {
7796 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nb\n"))
7797 .expect_err("truncated file must not parse");
7798 assert!(
7799 err.contains("`b` (variable-bounds) segment"),
7800 "error should name the missing segment: {err}"
7801 );
7802 }
7803
7804 /// The cut that leaves every bound in place and takes only the
7805 /// coefficients. Neither presence check can see it; the declared-vs-
7806 /// parsed nonzero count is the whole of the evidence.
7807 #[test]
7808 fn truncation_before_the_jacobian_is_a_parse_error() {
7809 let err = parse_nl_text(&truncate_before(WITH_CON_SUFFIX, "\nk1\n"))
7810 .expect_err("truncated file must not parse");
7811 assert!(
7812 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 0"),
7813 "error should report the mismatch: {err}"
7814 );
7815 }
7816
7817 /// The mismatch is an equality, not a floor: a file supplying *more*
7818 /// entries than it declares is as corrupt as one supplying fewer, and a
7819 /// `>=` check would wave it through.
7820 #[test]
7821 fn more_jacobian_entries_than_declared_is_also_a_parse_error() {
7822 let extra = WITH_CON_SUFFIX.replace("J1 2\n0 1\n1 -1\n", "J1 2\n0 1\n1 -1\nJ0 1\n0 5\n");
7823 let err = parse_nl_text(&extra).expect_err("over-full file must not parse");
7824 assert!(
7825 err.contains("declares 4 Jacobian nonzero(s) but the J segments supply 5"),
7826 "error should report the mismatch: {err}"
7827 );
7828 }
7829
7830 /// Fill a `ScalingRequest` from `tnlp` sized for this fixture
7831 /// (n = m = 2) and hand back everything the engine would see.
7832 fn scaling_of(tnlp: &mut NlTnlp) -> (bool, Number, bool, Vec<Number>, bool, Vec<Number>) {
7833 let mut obj = 1.0;
7834 let mut use_x = false;
7835 let mut x = vec![0.0; 2];
7836 let mut use_g = false;
7837 let mut g = vec![0.0; 2];
7838 let ok = tnlp.get_scaling_parameters(ScalingRequest {
7839 obj_scaling: &mut obj,
7840 use_x_scaling: &mut use_x,
7841 x_scaling: &mut x,
7842 use_g_scaling: &mut use_g,
7843 g_scaling: &mut g,
7844 });
7845 (ok, obj, use_x, x, use_g, g)
7846 }
7847
7848 /// gh#483: a `.nl` carrying Pyomo/AMPL `scaling_factor` suffixes on
7849 /// the objective (`S6`) and one constraint (`S5`) reaches the
7850 /// engine's `user-scaling` pathway. The untagged second row is
7851 /// unscaled — its AMPL suffix default is 0, which is not a usable
7852 /// scale factor and reads as "not tagged".
7853 #[test]
7854 fn scaling_factor_suffix_feeds_obj_and_constraint_scaling() {
7855 let nl = WITH_CON_SUFFIX.to_string()
7856 + "S5 1 scaling_factor\n0 10.0\nS6 1 scaling_factor\n0 100.0\n";
7857 let p = parse_nl_text(&nl).expect("parse");
7858 let mut tnlp = NlTnlp::new(p);
7859 let (ok, obj, use_x, _x, use_g, g) = scaling_of(&mut tnlp);
7860 assert!(ok, "a tagged model must supply scaling");
7861 assert!((obj - 100.0).abs() < 1e-12, "obj_scaling={obj}");
7862 assert!(use_g);
7863 assert_eq!(g, vec![10.0, 1.0]);
7864 assert!(!use_x, "no variable suffix was declared");
7865 }
7866
7867 /// Variable-level `scaling_factor` entries are passed through, not
7868 /// dropped on the floor: `OrigIpoptNlp` does not model them and
7869 /// refuses the solve, which is the whole point of gh#483.
7870 #[test]
7871 fn scaling_factor_suffix_forwards_variable_factors() {
7872 let nl = WITH_CON_SUFFIX.to_string() + "S4 1 scaling_factor\n1 3.0\n";
7873 let p = parse_nl_text(&nl).expect("parse");
7874 let mut tnlp = NlTnlp::new(p);
7875 let (ok, _obj, use_x, x, _use_g, _g) = scaling_of(&mut tnlp);
7876 assert!(ok);
7877 assert!(use_x, "variable factors must reach the engine");
7878 assert_eq!(x, vec![1.0, 3.0]);
7879 }
7880
7881 /// gh #703: with curvature-based scaling switched on, the computed
7882 /// factors are the base and a `scaling_factor` suffix the model
7883 /// actually carries wins **component by component** — an explicit
7884 /// factor from the modeller beats one inferred from the coefficients,
7885 /// and the components they did not tag keep the inferred one rather
7886 /// than snapping back to 1.
7887 #[test]
7888 fn a_user_suffix_overrides_the_computed_factor_component_wise() {
7889 let baseline = {
7890 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
7891 let mut t = NlTnlp::new(p);
7892 assert!(t.enable_curvature_scaling(), "an LP is degree ≤ 2");
7893 scaling_of(&mut t).5
7894 };
7895 assert!(
7896 baseline.iter().all(|v| *v > 0.0),
7897 "curvature scaling should produce usable row factors, got {baseline:?}"
7898 );
7899
7900 // Tag row 0 only. AMPL's untagged default is 0, which must read as
7901 // "not tagged" and leave row 1 on the computed factor.
7902 let nl = WITH_CON_SUFFIX.to_string() + "S5 1 scaling_factor\n0 7.0\n";
7903 let p = parse_nl_text(&nl).expect("parse");
7904 let mut t = NlTnlp::new(p);
7905 assert!(t.enable_curvature_scaling());
7906 let (ok, _obj, use_x, _x, use_g, g) = scaling_of(&mut t);
7907 assert!(ok);
7908 assert!(use_g && use_x);
7909 assert_eq!(g[0], 7.0, "the tagged row takes the user's factor");
7910 assert_eq!(
7911 g[1], baseline[1],
7912 "the untagged row keeps the computed one, not 1.0"
7913 );
7914 }
7915
7916 /// No `scaling_factor` suffix ⇒ "the user supplied nothing", the
7917 /// same answer the default `TNLP` impl gives, so `user-scaling`
7918 /// falls back to no scaling instead of a bogus all-zero vector.
7919 #[test]
7920 fn no_scaling_factor_suffix_declines() {
7921 let p = parse_nl_text(WITH_CON_SUFFIX).expect("parse");
7922 let mut tnlp = NlTnlp::new(p);
7923 let (ok, ..) = scaling_of(&mut tnlp);
7924 assert!(!ok);
7925 }
7926
7927 #[test]
7928 fn rejects_suffix_with_out_of_range_index() {
7929 let bad = WITH_CON_SUFFIX.replace("1 2\n", "5 2\n"); // m=2, idx=5 invalid
7930 let err = parse_nl_text(&bad).expect_err("must reject");
7931 assert!(
7932 err.contains("out of range"),
7933 "expected out-of-range error, got: {err}"
7934 );
7935 }
7936
7937 #[test]
7938 fn tnlp_round_trip_solves() {
7939 let p = parse_nl_text(SIMPLE).expect("parse");
7940 let mut tnlp = NlTnlp::new(p);
7941 let info = tnlp.get_nlp_info().unwrap();
7942 assert_eq!(info.n, 2);
7943 assert_eq!(info.m, 0);
7944 let f0 = tnlp.eval_f(&[0.0, 0.0], true).unwrap();
7945 assert!((f0 - 5.0).abs() < 1e-12);
7946 let mut g = [0.0_f64; 2];
7947 tnlp.eval_grad_f(&[0.0, 0.0], true, &mut g);
7948 // d/dx0 at x=0: 2*(0-1) = -2; d/dx1: 2*(0-2) = -4
7949 assert!((g[0] - (-2.0)).abs() < 1e-12);
7950 assert!((g[1] - (-4.0)).abs() < 1e-12);
7951 }
7952
7953 // ---- Sibling `.col` / `.row` name-file capture --------------------
7954 //
7955 // Names let diagnostics name the offending equation instead of "row 3"
7956 // (Lee et al. 2024, https://doi.org/10.69997/sct.147875). These cover
7957 // the read path and the documented fallback-to-empty behavior.
7958
7959 use pounce_nlp::expression_provider::ExpressionProvider;
7960 use std::sync::atomic::{AtomicUsize, Ordering};
7961
7962 /// Unique scratch dir for one test (no `tempfile` dev-dep available).
7963 fn scratch_dir(tag: &str) -> std::path::PathBuf {
7964 static N: AtomicUsize = AtomicUsize::new(0);
7965 let seq = N.fetch_add(1, Ordering::Relaxed);
7966 let dir = std::env::temp_dir().join(format!(
7967 "pounce_nlnames_{}_{}_{}",
7968 std::process::id(),
7969 tag,
7970 seq
7971 ));
7972 std::fs::create_dir_all(&dir).expect("create scratch dir");
7973 dir
7974 }
7975
7976 #[test]
7977 fn read_name_file_reads_in_order() {
7978 let dir = scratch_dir("col_order");
7979 let p = dir.join("m.col");
7980 std::fs::write(&p, "x_in\nT_reactor\nflow\n").unwrap();
7981 assert_eq!(read_name_file(&p, 3), vec!["x_in", "T_reactor", "flow"]);
7982 }
7983
7984 #[test]
7985 fn read_name_file_truncates_extra_lines() {
7986 // `.row` conventionally appends the objective name after the m
7987 // constraint names; `.take(expected)` must drop it so names stay
7988 // 1:1 with `g`.
7989 let dir = scratch_dir("row_obj");
7990 let p = dir.join("m.row");
7991 std::fs::write(&p, "mass_balance\nenergy_balance\nobj\n").unwrap();
7992 assert_eq!(
7993 read_name_file(&p, 2),
7994 vec!["mass_balance", "energy_balance"]
7995 );
7996 }
7997
7998 #[test]
7999 fn read_name_file_empty_on_short_or_missing() {
8000 let dir = scratch_dir("short");
8001 let short = dir.join("m.col");
8002 std::fs::write(&short, "only_one\n").unwrap();
8003 // Fewer lines than expected ⇒ empty (never a partial mapping).
8004 assert!(read_name_file(&short, 3).is_empty());
8005 // Missing file ⇒ empty, no error.
8006 assert!(read_name_file(&dir.join("absent.col"), 2).is_empty());
8007 }
8008
8009 #[test]
8010 fn read_nl_file_captures_sibling_names() {
8011 // SIMPLE is n=2, m=0. Drop a `.col` next to it and confirm the
8012 // names ride through onto the TNLP's ExpressionProvider.
8013 let dir = scratch_dir("sibling");
8014 let nl = dir.join("m.nl");
8015 std::fs::write(&nl, SIMPLE).unwrap();
8016 std::fs::write(dir.join("m.col"), "alpha\nbeta\n").unwrap();
8017
8018 let prob = read_nl_file(&nl).expect("parse + name capture");
8019 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
8020 assert!(prob.con_names.is_empty()); // no `.row` written, m=0 anyway
8021
8022 let tnlp = NlTnlp::new(prob);
8023 assert_eq!(tnlp.variable_name(0), Some("alpha"));
8024 assert_eq!(tnlp.variable_name(1), Some("beta"));
8025 assert_eq!(tnlp.variable_name(2), None); // out of range ⇒ index fallback
8026 }
8027
8028 #[test]
8029 fn read_nl_file_without_names_yields_empty() {
8030 let dir = scratch_dir("noname");
8031 let nl = dir.join("m.nl");
8032 std::fs::write(&nl, SIMPLE).unwrap();
8033 let prob = read_nl_file(&nl).expect("parse");
8034 assert!(prob.var_names.is_empty());
8035 assert!(prob.con_names.is_empty());
8036 let tnlp = NlTnlp::new(prob);
8037 assert_eq!(tnlp.variable_name(0), None);
8038 }
8039
8040 #[test]
8041 fn read_nl_file_resolves_extensionless_ampl_stub() {
8042 // AMPL invokes `pounce mystub -AMPL`, passing the stub *without*
8043 // the `.nl` extension; the solver must read `mystub.nl`. Code
8044 // review 2026-06 item M15.
8045 let dir = scratch_dir("stub");
8046 std::fs::write(dir.join("mystub.nl"), SIMPLE).unwrap();
8047 // Pass the extensionless stub — the file `mystub` does not exist.
8048 let stub = dir.join("mystub");
8049 assert!(!stub.exists(), "stub must be extensionless / absent");
8050 let prob = read_nl_file(&stub).expect("stub should resolve to mystub.nl");
8051 assert_eq!(prob.n, 2);
8052 assert_eq!(prob.m, 0);
8053
8054 // Sibling name files are still found off the resolved stem.
8055 std::fs::write(dir.join("mystub.col"), "alpha\nbeta\n").unwrap();
8056 let prob = read_nl_file(&stub).expect("stub resolves, names ride along");
8057 assert_eq!(prob.var_names, vec!["alpha", "beta"]);
8058 }
8059
8060 #[test]
8061 fn read_nl_file_prefers_exact_path_over_nl_sibling() {
8062 // An existing path is read verbatim — the `.nl` fallback only
8063 // kicks in when the literal path is missing, so a caller passing a
8064 // real file is never silently redirected to a `<file>.nl` sibling.
8065 let dir = scratch_dir("exact");
8066 // `data` exists and IS a valid .nl; `data.nl` is deliberate garbage.
8067 std::fs::write(dir.join("data"), SIMPLE).unwrap();
8068 std::fs::write(dir.join("data.nl"), "not an nl file").unwrap();
8069 let prob = read_nl_file(&dir.join("data")).expect("exact path wins");
8070 assert_eq!(prob.n, 2);
8071 }
8072
8073 #[test]
8074 fn append_extension_appends_rather_than_replaces() {
8075 use std::path::Path;
8076 assert_eq!(
8077 append_extension(Path::new("mystub"), "nl"),
8078 Path::new("mystub.nl")
8079 );
8080 // A stub that itself contains a dot keeps its stem (AMPL names it
8081 // `my.model.nl`, not `my.nl`).
8082 assert_eq!(
8083 append_extension(Path::new("my.model"), "nl"),
8084 Path::new("my.model.nl")
8085 );
8086 }
8087
8088 // ---- equation rendering (`print equation`) ----
8089
8090 fn names(v: &[&str]) -> Vec<String> {
8091 v.iter().map(|s| s.to_string()).collect()
8092 }
8093
8094 #[test]
8095 fn render_uses_variable_names_when_present() {
8096 let e = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
8097 assert_eq!(render_expr(&e, &names(&["T", "flow"]), &[]), "T*flow");
8098 // Falls back to x[i] when names are absent.
8099 assert_eq!(render_expr(&e, &[], &[]), "x[0]*x[1]");
8100 }
8101
8102 #[test]
8103 fn render_parenthesizes_by_precedence() {
8104 // (x0 + x1) * x2 must keep the parens around the sum.
8105 let sum = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(Expr::Var(1)));
8106 let e = Expr::Binary(BinOp::Mul, Box::new(sum), Box::new(Expr::Var(2)));
8107 assert_eq!(render_expr(&e, &[], &[]), "(x[0] + x[1])*x[2]");
8108
8109 // x0 + x1 * x2 needs no parens (mul binds tighter).
8110 let mul = Expr::Binary(BinOp::Mul, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
8111 let e2 = Expr::Binary(BinOp::Add, Box::new(Expr::Var(0)), Box::new(mul));
8112 assert_eq!(render_expr(&e2, &[], &[]), "x[0] + x[1]*x[2]");
8113 }
8114
8115 #[test]
8116 fn render_subtraction_right_assoc_parens() {
8117 // x0 - (x1 - x2) keeps the parens; x0 - x1 - x2 does not.
8118 let inner = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(1)), Box::new(Expr::Var(2)));
8119 let e = Expr::Binary(BinOp::Sub, Box::new(Expr::Var(0)), Box::new(inner));
8120 assert_eq!(render_expr(&e, &[], &[]), "x[0] - (x[1] - x[2])");
8121 }
8122
8123 #[test]
8124 fn render_functions_and_pow() {
8125 let sq = Expr::Binary(
8126 BinOp::Pow,
8127 Box::new(Expr::Var(0)),
8128 Box::new(Expr::Const(2.0)),
8129 );
8130 let e = Expr::Unary(UnaryOp::Exp, Box::new(sq));
8131 assert_eq!(render_expr(&e, &names(&["q"]), &[]), "exp(q^2)");
8132 }
8133
8134 #[test]
8135 fn render_linear_signs_are_tidy() {
8136 // 1*a - 2*b + c (coef +1 omits the multiplier).
8137 let lin = vec![(0usize, 1.0), (1, -2.0), (2, 1.0)];
8138 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 2*b + c");
8139 }
8140
8141 #[test]
8142 fn render_linear_skips_zero_coefficients() {
8143 // A 0 coefficient (a variable present only in the nonlinear part)
8144 // is dropped, not rendered as `0*x`.
8145 let lin = vec![(0usize, 1.0), (1, 0.0), (2, -3.0)];
8146 assert_eq!(render_linear(&lin, &names(&["a", "b", "c"])), "a - 3*c");
8147 // Leading term zero ⇒ the first emitted term still has no ` + `.
8148 let lin = vec![(0usize, 0.0), (1, 2.0)];
8149 assert_eq!(render_linear(&lin, &names(&["a", "b"])), "2*b");
8150 }
8151
8152 #[test]
8153 fn render_sum_folds_negative_terms() {
8154 // Σ(a², -b⁴, -c) reads `a^2 - b^4 - c`, not `a^2 + -b^4 + -c`.
8155 let sq = |i| {
8156 Expr::Binary(
8157 BinOp::Pow,
8158 Box::new(Expr::Var(i)),
8159 Box::new(Expr::Const(2.0)),
8160 )
8161 };
8162 let neg = |i| {
8163 Expr::Binary(
8164 BinOp::Mul,
8165 Box::new(Expr::Const(-1.0)),
8166 Box::new(Expr::Var(i)),
8167 )
8168 };
8169 let e = Expr::Sum(vec![
8170 sq(0),
8171 neg(1),
8172 Expr::Unary(UnaryOp::Neg, Box::new(Expr::Var(2))),
8173 ]);
8174 assert_eq!(
8175 render_expr(&e, &names(&["a", "b", "c"]), &[]),
8176 "a^2 - 1*b - c"
8177 );
8178 }
8179
8180 #[test]
8181 fn render_constraint_equation_forms() {
8182 // Build a 2-constraint problem by hand: an equality and a range.
8183 let mut prob = parse_nl_text(SIMPLE).unwrap();
8184 // Overwrite to a known small shape: 1 var, 2 cons.
8185 prob.n = 2;
8186 prob.m = 2;
8187 prob.var_names = names(&["mass_in", "mass_out"]);
8188 prob.con_names = names(&["balance", "window"]);
8189 prob.con_linear = vec![
8190 vec![(0, 1.0), (1, -1.0)], // mass_in - mass_out
8191 vec![(0, 1.0)], // mass_in
8192 ];
8193 prob.con_nonlinear = vec![
8194 NlBody::Tree(Expr::Const(0.0)),
8195 NlBody::Tree(Expr::Const(0.0)),
8196 ];
8197 prob.g_l = vec![0.0, 0.0];
8198 prob.g_u = vec![0.0, 500.0];
8199
8200 assert_eq!(
8201 render_constraint_equation(&prob, 0),
8202 "mass_in - mass_out = 0"
8203 );
8204 assert_eq!(render_constraint_equation(&prob, 1), "0 <= mass_in <= 500");
8205
8206 let all = render_all_constraint_equations(&prob);
8207 assert_eq!(all.len(), 2);
8208 assert_eq!(all[1], "0 <= mass_in <= 500");
8209 }
8210
8211 #[test]
8212 fn constraint_jacobian_sparsity_unions_linear_and_nonlinear() {
8213 let mut prob = parse_nl_text(SIMPLE).unwrap();
8214 prob.n = 3;
8215 prob.m = 2;
8216 // Row 0: linear in x1, nonlinear in x0 and x2 → support {0,1,2}.
8217 // Row 1: linear in x2 only → support {2}.
8218 prob.con_linear = vec![vec![(1, 4.0)], vec![(2, 1.0)]];
8219 prob.con_nonlinear = vec![
8220 NlBody::Tree(Expr::Binary(
8221 BinOp::Mul,
8222 Box::new(Expr::Var(0)),
8223 Box::new(Expr::Var(2)),
8224 )),
8225 NlBody::Tree(Expr::Const(0.0)),
8226 ];
8227 prob.g_l = vec![0.0, 0.0];
8228 prob.g_u = vec![0.0, 0.0];
8229
8230 let (irow, jcol) = constraint_jacobian_sparsity(&prob);
8231 // Sorted, deduped per row: row 0 → cols 0,1,2; row 1 → col 2.
8232 assert_eq!(irow, vec![0, 0, 0, 1]);
8233 assert_eq!(jcol, vec![0, 1, 2, 2]);
8234 }
8235
8236 #[test]
8237 fn funcall_string_arg_with_hash_is_not_truncated() {
8238 // Code review L31: an AMPL string argument is a Hollerith literal
8239 // `h<len>:<chars>` whose content is exactly <len> bytes and may
8240 // legitimately contain '#' (e.g. a parameters-directory path). The
8241 // old parser ran strip_comment() over the line first, truncating
8242 // the content at the '#'. Here `h3:a#b` must round-trip to "a#b".
8243 let mut p = Parser::new("h3:a#b\n", false);
8244 match p.parse_funcall_arg().expect("parse hollerith arg") {
8245 FuncallArg::Str(s) => assert_eq!(s, "a#b"),
8246 other => panic!("expected Str, got {other:?}"),
8247 }
8248 }
8249
8250 #[test]
8251 fn funcall_string_arg_honors_declared_length() {
8252 // The declared `<len>` is authoritative: exactly that many bytes
8253 // after the ':' form the string; trailing content (here a real
8254 // ` # comment`) is not part of it.
8255 let mut p = Parser::new("h3:abc # trailing comment\n", false);
8256 match p.parse_funcall_arg().expect("parse hollerith arg") {
8257 FuncallArg::Str(s) => assert_eq!(s, "abc"),
8258 other => panic!("expected Str, got {other:?}"),
8259 }
8260 }
8261
8262 // --- AMPL power specializations (opcodes o81/o82/o83) --------------------
8263 //
8264 // AMPL emits these in place of the general `o5` (OPPOW) when one operand
8265 // is constant. They must parse to the same `Pow` AST as `o5` so the tape's
8266 // negative-base-safe constant-power lowering applies. The eval points below
8267 // are chosen to pin down BOTH the arity and the operand order: a swapped
8268 // `base`/`exp` (or treating `o82` as a different unary op) gives a
8269 // different number at these points, so each assertion is discriminating.
8270
8271 /// Parse a single expression `expr_src` with `n` variables in scope,
8272 /// driving the real `parse_opcode` path through `parse_expr`.
8273 fn parse_one_expr(n: usize, expr_src: &str) -> Expr {
8274 let mut p = Parser::new(expr_src, false);
8275 p.n = n;
8276 p.parse_expr().expect("parse expression")
8277 }
8278
8279 #[test]
8280 fn opcode_o82_square_is_unary_pow_of_two() {
8281 // o82 OP2POW: `x^2`, unary — one operand, implicit exponent 2.
8282 let e = parse_one_expr(1, "o82\nv0\n");
8283 match &e {
8284 Expr::Binary(BinOp::Pow, base, exp) => {
8285 assert!(matches!(**base, Expr::Var(0)));
8286 match **exp {
8287 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "exp const = {c}"),
8288 ref other => panic!("o82 exponent must be Const(2.0), got {other:?}"),
8289 }
8290 }
8291 other => panic!("o82 must parse to Pow(base, 2), got {other:?}"),
8292 }
8293 // value: 3^2 = 9, and — the whole point of o82 — a NEGATIVE base stays
8294 // real: (-3)^2 = 9 (general `exp(2·ln x)` would be NaN here).
8295 assert!((eval_expr(&e, &[3.0]) - 9.0).abs() < 1e-12);
8296 assert!((eval_expr(&e, &[-3.0]) - 9.0).abs() < 1e-12);
8297 // gradient d/dx x^2 = 2x: 6 at x=3, -6 at x=-3 (real on both sides).
8298 let mut g = [0.0_f64; 1];
8299 grad_expr(&e, &[3.0], 1.0, &mut g);
8300 assert!((g[0] - 6.0).abs() < 1e-9, "grad at 3 = {}", g[0]);
8301 g[0] = 0.0;
8302 grad_expr(&e, &[-3.0], 1.0, &mut g);
8303 assert!((g[0] + 6.0).abs() < 1e-9, "grad at -3 = {}", g[0]);
8304 }
8305
8306 #[test]
8307 fn opcode_o81_const_exponent_is_base_pow_const() {
8308 // o81 OP1POW: `base ^ const`, binary, operands `base` then `exp`.
8309 let e = parse_one_expr(1, "o81\nv0\nn3\n");
8310 match &e {
8311 Expr::Binary(BinOp::Pow, base, exp) => {
8312 assert!(matches!(**base, Expr::Var(0)), "base must be the variable");
8313 match **exp {
8314 Expr::Const(c) => assert!((c - 3.0).abs() < 1e-12, "exp const = {c}"),
8315 ref other => panic!("o81 exponent must be Const(3.0), got {other:?}"),
8316 }
8317 }
8318 other => panic!("o81 must parse to Pow(var, const), got {other:?}"),
8319 }
8320 // x^3 at x=2 is 8, NOT 3^2=9 — pins operand order (base^exp, not exp^base).
8321 assert!((eval_expr(&e, &[2.0]) - 8.0).abs() < 1e-12);
8322 // NEGATIVE base, odd integer exponent: (-2)^3 = -8. This is exactly the
8323 // case the general `pow` (exp(3·ln x)) cannot do — it returns NaN.
8324 assert!((eval_expr(&e, &[-2.0]) + 8.0).abs() < 1e-12);
8325 // gradient d/dx x^3 = 3x^2 = 12 at x=2.
8326 let mut g = [0.0_f64; 1];
8327 grad_expr(&e, &[2.0], 1.0, &mut g);
8328 assert!((g[0] - 12.0).abs() < 1e-9, "grad at 2 = {}", g[0]);
8329 }
8330
8331 #[test]
8332 fn opcode_o83_const_base_is_const_pow_exp() {
8333 // o83 OPCPOW: `const ^ exp`, binary, operands `base` (the const) then `exp`.
8334 let e = parse_one_expr(1, "o83\nn2\nv0\n");
8335 match &e {
8336 Expr::Binary(BinOp::Pow, base, exp) => {
8337 match **base {
8338 Expr::Const(c) => assert!((c - 2.0).abs() < 1e-12, "base const = {c}"),
8339 ref other => panic!("o83 base must be Const(2.0), got {other:?}"),
8340 }
8341 assert!(
8342 matches!(**exp, Expr::Var(0)),
8343 "exponent must be the variable"
8344 );
8345 }
8346 other => panic!("o83 must parse to Pow(const, var), got {other:?}"),
8347 }
8348 // 2^x at x=3 is 8, NOT x^2=9 at x=3 — pins operand order (const^exp).
8349 assert!((eval_expr(&e, &[3.0]) - 8.0).abs() < 1e-12);
8350 assert!((eval_expr(&e, &[0.0]) - 1.0).abs() < 1e-12);
8351 // gradient d/dx 2^x = 2^x · ln 2; at x=3 that is 8·ln2.
8352 let mut g = [0.0_f64; 1];
8353 grad_expr(&e, &[3.0], 1.0, &mut g);
8354 assert!(
8355 (g[0] - 8.0 * 2.0_f64.ln()).abs() < 1e-9,
8356 "grad at 3 = {} (want {})",
8357 g[0],
8358 8.0 * 2.0_f64.ln()
8359 );
8360 }
8361
8362 #[test]
8363 fn power_specializations_agree_with_general_o5() {
8364 // Where both are defined, o81/o82/o83 must be numerically identical to
8365 // the general `o5` pow on the same operands — they are only routing
8366 // hints, not different math.
8367 let o5_sq = parse_one_expr(1, "o5\nv0\nn2\n"); // x^2
8368 let o82 = parse_one_expr(1, "o82\nv0\n");
8369 let o5_cube = parse_one_expr(1, "o5\nv0\nn3\n"); // x^3
8370 let o81 = parse_one_expr(1, "o81\nv0\nn3\n");
8371 let o5_exp = parse_one_expr(1, "o5\nn2\nv0\n"); // 2^x
8372 let o83 = parse_one_expr(1, "o83\nn2\nv0\n");
8373 for &x in &[-2.0_f64, -0.5, 0.0, 1.0, 2.5, 4.0] {
8374 assert!((eval_expr(&o82, &[x]) - eval_expr(&o5_sq, &[x])).abs() < 1e-12);
8375 assert!((eval_expr(&o81, &[x]) - eval_expr(&o5_cube, &[x])).abs() < 1e-12);
8376 // 2^x is real for all x; compare across the same points.
8377 assert!((eval_expr(&o83, &[x]) - eval_expr(&o5_exp, &[x])).abs() < 1e-12);
8378 }
8379 }
8380
8381 #[test]
8382 fn power_opcodes_round_trip_through_parse_nl_text() {
8383 // End-to-end through the public entry point: `min x0^2 + x1^2` written
8384 // with o82 (square) parses and evaluates like its o5 twin. Reuses the
8385 // SIMPLE header (n=2, m=0, both vars nonlinear in the objective).
8386 let nl = SIMPLE.replace(
8387 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
8388 "o0\no82\nv0\no82\nv1\n",
8389 );
8390 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
8391 let p = parse_nl_text(&nl).expect("parse o82 objective");
8392 // f(3,4) = 9 + 16 = 25; both bases negative still real: f(-3,-4)=25.
8393 assert!((eval_expr(&p.obj_expr(), &[3.0, 4.0]) - 25.0).abs() < 1e-12);
8394 assert!((eval_expr(&p.obj_expr(), &[-3.0, -4.0]) - 25.0).abs() < 1e-12);
8395 }
8396
8397 #[test]
8398 fn power_opcode_o81_evaluates_through_the_tape_at_negative_base() {
8399 // Full production path: parse o81 -> build the tape -> eval_f/eval_grad_f.
8400 // `min x0^3 + x1^3` lowers each cube to an integer-power mul chain
8401 // (the negative-base-safe path) rather than a generic `powf`. The check
8402 // at a NEGATIVE base is the one that would break if o81 wrongly routed
8403 // through `exp(c·ln x)`: (-2)^3 must be -8, not NaN.
8404 let nl = SIMPLE.replace(
8405 "o0\no5\no1\nv0\nn1\nn2\no5\no1\nv1\nn2\nn2\n",
8406 "o0\no81\nv0\nn3\no81\nv1\nn3\n",
8407 );
8408 assert_ne!(nl, SIMPLE, "fixture substitution must apply");
8409 let p = parse_nl_text(&nl).expect("parse o81 objective");
8410 let mut tnlp = NlTnlp::new(p);
8411 tnlp.get_nlp_info().unwrap();
8412 // f(-2, 1) = (-2)^3 + 1^3 = -8 + 1 = -7 (real, not NaN).
8413 let f = tnlp.eval_f(&[-2.0, 1.0], true).unwrap();
8414 assert!((f + 7.0).abs() < 1e-12, "f(-2,1) = {f}");
8415 // grad = (3 x0^2, 3 x1^2) = (12, 3) at (-2, 1).
8416 let mut g = [0.0_f64; 2];
8417 assert!(tnlp.eval_grad_f(&[-2.0, 1.0], true, &mut g));
8418 assert!((g[0] - 12.0).abs() < 1e-9, "df/dx0 = {}", g[0]);
8419 assert!((g[1] - 3.0).abs() < 1e-9, "df/dx1 = {}", g[1]);
8420 }
8421
8422 // ---- Shared-CSE constraint tape (issue #476) ----------------------
8423
8424 /// Three constraints over one `V` segment (a `.nl` *defined variable*),
8425 /// `V3 = 2*x0 + 3*x1`, referenced by all three:
8426 /// C0: V3^2 C1: V3^3 + x2 C2: V3*x2
8427 /// `{BODY2}` is a substitution point so a variant can drop an opcode the
8428 /// hybrid path rejects into C2.
8429 const SHARED_CSE: &str = "g3 1 1 0
8430 3 3 1 0 0
8431 3 0
8432 0 0
8433 3 0 0
8434 0 0 0 1
8435 0 0 0 0 0
8436 8 3
8437 0 0
8438 0 1 0 0 0
8439V3 2 0
84400 2.0
84411 3.0
8442n0
8443C0
8444o5
8445v3
8446n2
8447C1
8448o0
8449o5
8450v3
8451n3
8452v2
8453C2
8454{BODY2}
8455O0 0
8456n0
8457r
84582 0
84592 0
84602 0
8461b
84623
84633
84643
8465k2
84663
84676
8468J0 2
84690 0
84701 0
8471J1 3
84720 0
84731 0
84742 0
8475J2 3
84760 0
84771 0
84782 0
8479G0 3
84800 1.0
84811 1.0
84822 1.0
8483";
8484
8485 fn shared_cse_nl(body2: &str) -> String {
8486 SHARED_CSE.replace("{BODY2}", body2)
8487 }
8488
8489 /// `eval_jac_g`'s shared-CSE path must return exactly what the flat
8490 /// per-summand tapes return — it is a different traversal of the same
8491 /// DAG, not a different derivative. Forced on here regardless of
8492 /// `HYBRID_JAC_MIN_OP_RATIO` so the path is covered independently of
8493 /// the size heuristic that decides when to use it.
8494 #[test]
8495 fn shared_cse_jacobian_matches_flat_tape_bit_for_bit() {
8496 let nl = shared_cse_nl("o2\nv3\nv2");
8497 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
8498
8499 let mut hybrid = NlTnlp::new(p.clone());
8500 let info = hybrid.get_nlp_info().unwrap();
8501 let nnz = info.nnz_jac_g as usize;
8502 hybrid
8503 .con_hybrid
8504 .as_mut()
8505 .expect("CSE shared by 3 constraints must build the hybrid tape")
8506 .use_for_jac = true;
8507
8508 let mut flat = NlTnlp::new(p);
8509 flat.get_nlp_info().unwrap();
8510 flat.con_hybrid = None;
8511
8512 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
8513 let mut jh = vec![0.0_f64; nnz];
8514 let mut jf = vec![0.0_f64; nnz];
8515 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
8516 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
8517 assert_eq!(
8518 jh, jf,
8519 "hybrid Jacobian differs from the flat tape at {x:?}"
8520 );
8521
8522 // V3 = 2 x0 + 3 x1; rows are V3^2, V3^3 + x2, V3 * x2.
8523 let s = 2.0 * x[0] + 3.0 * x[1];
8524 let want = [
8525 4.0 * s,
8526 6.0 * s,
8527 6.0 * s * s,
8528 9.0 * s * s,
8529 1.0,
8530 2.0 * x[2],
8531 3.0 * x[2],
8532 s,
8533 ];
8534 assert_eq!(nnz, want.len());
8535 for k in 0..nnz {
8536 assert!(
8537 (jh[k] - want[k]).abs() < 1e-9,
8538 "entry {k} at {x:?}: got {}, want {}",
8539 jh[k],
8540 want[k]
8541 );
8542 }
8543 }
8544 }
8545
8546 /// `.nl` text for `m` constraints `S * x_{i+2} >= 0`, all sharing one
8547 /// CSE `S = body(x0, x1)` (the caller passes the body's expression
8548 /// text, which must reference exactly `v0` and `v1`). A deep shared
8549 /// body over few local ops per row is the regime the op-ratio gates
8550 /// are meant to catch: no repository fixture has a CSE shared across
8551 /// constraints at all, so without this the gate-on paths have no
8552 /// natural coverage.
8553 fn shared_body_chain_nl(m: usize, body: &str) -> String {
8554 let n = m + 2;
8555 let nzc = 3 * m;
8556 let mut s = String::new();
8557 s.push_str("g3 1 1 0\n");
8558 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
8559 s.push_str(&format!(" {m} 0\n 0 0\n"));
8560 s.push_str(&format!(" {n} 0 0\n"));
8561 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8562 s.push_str(&format!(" {nzc} {n}\n"));
8563 s.push_str(" 0 0\n 0 1 0 0 0\n");
8564 // The shared body.
8565 s.push_str(&format!("V{n} 0 0\n"));
8566 s.push_str(body);
8567 // Rows: S * x_{i+2}.
8568 for i in 0..m {
8569 s.push_str(&format!("C{i}\no2\nv{n}\nv{}\n", i + 2));
8570 }
8571 s.push_str("O0 0\nn0\n");
8572 s.push_str(&format!("x{n}\n"));
8573 for j in 0..n {
8574 s.push_str(&format!("{j} {}\n", 0.3 + 0.05 * j as f64));
8575 }
8576 s.push_str("r\n");
8577 for _ in 0..m {
8578 s.push_str("2 0\n");
8579 }
8580 s.push_str("b\n");
8581 for _ in 0..n {
8582 s.push_str("3\n");
8583 }
8584 // Column counts: cols 0 and 1 appear in every row, col i+2 in one.
8585 s.push_str(&format!("k{}\n", n - 1));
8586 let mut acc = 0;
8587 for j in 0..n - 1 {
8588 acc += if j < 2 { m } else { 1 };
8589 s.push_str(&format!("{acc}\n"));
8590 }
8591 for i in 0..m {
8592 s.push_str(&format!("J{i} 3\n0 0.0\n1 0.0\n{} 0.0\n", i + 2));
8593 }
8594 s.push_str(&format!("G0 {n}\n"));
8595 for j in 0..n {
8596 s.push_str(&format!("{j} 0.0\n"));
8597 }
8598 s
8599 }
8600
8601 /// [`shared_body_chain_nl`] with `S = exp^depth(0.01 * (x0 + x1))`.
8602 /// The forward value saturates to `inf` past depth ≈ 5, which the
8603 /// Jacobian tests tolerate (`inf == inf`); Hessian tests need the
8604 /// bounded variant below instead, whose second-order terms would
8605 /// otherwise mix `inf`s of both signs into `NaN`.
8606 fn deep_shared_cse_nl(m: usize, depth: usize) -> String {
8607 let mut body = String::new();
8608 for _ in 0..depth {
8609 body.push_str("o44\n");
8610 }
8611 body.push_str("o2\nn0.01\no0\nv0\nv1\n");
8612 shared_body_chain_nl(m, &body)
8613 }
8614
8615 /// [`shared_body_chain_nl`] with `S = (log ∘ exp)^pairs(2 + 0.01 *
8616 /// (x0 + x1))` — mathematically the identity chain, so the value
8617 /// stays bounded (no `inf`/`NaN` at any reasonable `x`) while every
8618 /// stage still carries nonzero curvature (`exp'' ≠ 0`, `log'' ≠ 0`)
8619 /// through both second-order sweeps. `2 * pairs` body ops drive the
8620 /// flat/shared op ratio as high as the Hessian gate tests need.
8621 fn bounded_deep_shared_cse_nl(m: usize, pairs: usize) -> String {
8622 let mut body = String::new();
8623 for _ in 0..pairs {
8624 body.push_str("o43\no44\n");
8625 }
8626 body.push_str("o0\nn2\no2\nn0.01\no0\nv0\nv1\n");
8627 shared_body_chain_nl(m, &body)
8628 }
8629
8630 /// With a deep shared body the gate turns itself on, and the path it
8631 /// turns on must still agree with the flat tapes exactly.
8632 #[test]
8633 fn a_deep_shared_body_turns_the_jacobian_gate_on_and_still_agrees() {
8634 let p = parse_nl_text(&deep_shared_cse_nl(16, 40)).expect("parse");
8635
8636 let mut hybrid = NlTnlp::new(p.clone());
8637 let info = hybrid.get_nlp_info().unwrap();
8638 let nnz = info.nnz_jac_g as usize;
8639 assert!(
8640 hybrid
8641 .con_hybrid
8642 .as_ref()
8643 .expect("shared CSE must build the hybrid tape")
8644 .use_for_jac,
8645 "a 40-deep body shared by 16 rows is well past the op-ratio gate"
8646 );
8647
8648 let mut flat = NlTnlp::new(p);
8649 flat.get_nlp_info().unwrap();
8650 flat.con_hybrid = None;
8651
8652 for scale in [1.0_f64, -0.7, 2.5] {
8653 let x: Vec<f64> = (0..info.n as usize)
8654 .map(|j| scale * (0.2 + 0.03 * j as f64))
8655 .collect();
8656 let mut jh = vec![0.0_f64; nnz];
8657 let mut jf = vec![0.0_f64; nnz];
8658 assert!(hybrid.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jh }));
8659 assert!(flat.eval_jac_g(Some(&x), true, SparsityRequest::Values { values: &mut jf }));
8660 assert_eq!(jh, jf, "gate-on Jacobian differs from the flat tape");
8661 assert!(jh.iter().any(|v| *v != 0.0), "all-zero Jacobian is no test");
8662 }
8663 }
8664
8665 /// The Jacobian gate is off for a model whose shared bodies are small,
8666 /// because there the hybrid traversal's per-op overhead outweighs the
8667 /// halved forward sweep. `eval_g` still takes the hybrid path — that
8668 /// one is a win at any ratio.
8669 #[test]
8670 fn a_small_shared_body_leaves_the_jacobian_on_the_flat_path() {
8671 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
8672 let mut t = NlTnlp::new(p);
8673 t.get_nlp_info().unwrap();
8674 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
8675 assert!(
8676 !h.use_for_jac,
8677 "a 3-row model with a 2-term CSE is far below the op-ratio gate"
8678 );
8679 }
8680
8681 /// `eval_h`'s shared-CSE path (issue #557) against the flat tapes on a
8682 /// polynomial model with dyadic inputs. Folding `λ_k` into the boundary
8683 /// adjoints and running one prelude sweep reassociates floating-point
8684 /// products, so the two paths agree only to rounding on general inputs —
8685 /// but here every operation in both traversals is exact (dyadic values,
8686 /// small-integer coefficients, polynomial ops), so the results must be
8687 /// bit-identical, pinning the arithmetic itself and not just its
8688 /// magnitude. Forced on regardless of `HYBRID_HESS_MIN_OP_RATIO` so the
8689 /// path is covered independently of the size heuristic.
8690 #[test]
8691 fn shared_cse_hessian_matches_flat_tape_bit_for_bit() {
8692 let nl = shared_cse_nl("o2\nv3\nv2");
8693 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
8694
8695 let mut hybrid = NlTnlp::new(p.clone());
8696 let info = hybrid.get_nlp_info().unwrap();
8697 let nnz = info.nnz_h_lag as usize;
8698 hybrid
8699 .con_hybrid
8700 .as_mut()
8701 .expect("CSE shared by 3 constraints must build the hybrid tape")
8702 .use_for_hess = true;
8703
8704 let mut flat = NlTnlp::new(p);
8705 flat.get_nlp_info().unwrap();
8706 flat.con_hybrid = None;
8707
8708 let pairs: Vec<(usize, usize)> = hybrid
8709 .h_irow
8710 .iter()
8711 .zip(&hybrid.h_jcol)
8712 .map(|(&i, &j)| (i as usize, j as usize))
8713 .collect();
8714
8715 // Two multiplier sets: one all-live, one with a dead row so the
8716 // λ == 0 skip is exercised on the hybrid path too.
8717 for lam in [[0.5, -1.25, 2.0], [0.0, 1.0, 0.5]] {
8718 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
8719 let mut hh = vec![0.0_f64; nnz];
8720 let mut hf = vec![0.0_f64; nnz];
8721 assert!(hybrid.eval_h(
8722 Some(&x),
8723 true,
8724 1.0,
8725 Some(&lam),
8726 true,
8727 SparsityRequest::Values { values: &mut hh }
8728 ));
8729 assert!(flat.eval_h(
8730 Some(&x),
8731 true,
8732 1.0,
8733 Some(&lam),
8734 true,
8735 SparsityRequest::Values { values: &mut hf }
8736 ));
8737 assert_eq!(
8738 hh, hf,
8739 "hybrid Hessian differs from the flat tape at {x:?}, λ = {lam:?}"
8740 );
8741
8742 // Analytic cross-check. V3 = 2 x0 + 3 x1 =: s with gradient
8743 // dV = (2, 3, 0); the rows are V3², V3³ + x2, V3·x2 and the
8744 // objective is constant, so the Lagrangian Hessian is
8745 // (2 λ0 + 6 s λ1) · dV dVᵀ + λ2 · (dV e2ᵀ + e2 dVᵀ).
8746 let s = 2.0 * x[0] + 3.0 * x[1];
8747 let q = 2.0 * lam[0] + 6.0 * s * lam[1];
8748 let dv = [2.0, 3.0, 0.0];
8749 for (k, &(i, j)) in pairs.iter().enumerate() {
8750 let mut want = q * dv[i] * dv[j];
8751 if i == 2 {
8752 want += lam[2] * dv[j];
8753 }
8754 if j == 2 {
8755 want += lam[2] * dv[i];
8756 }
8757 assert!(
8758 (hh[k] - want).abs() < 1e-9,
8759 "entry ({i}, {j}) at {x:?}, λ = {lam:?}: got {}, want {want}",
8760 hh[k]
8761 );
8762 }
8763 }
8764 }
8765 }
8766
8767 /// A deep (but bounded — see `bounded_deep_shared_cse_nl`) shared body
8768 /// turns the Hessian gate on by itself, and the path it turns on must
8769 /// agree with the flat tapes. Not bitwise here: the shared prelude
8770 /// reverse sweep runs once over the `λ_k`-folded adjoints of all
8771 /// summands where the flat path runs per summand, and that
8772 /// reassociation moves transcendental results by rounding — so the bar
8773 /// is a relative few-ULP band, with the exact-arithmetic case pinned
8774 /// bitwise by `shared_cse_hessian_matches_flat_tape_bit_for_bit`.
8775 #[test]
8776 fn a_deep_shared_body_turns_the_hessian_gate_on_and_still_agrees() {
8777 let m = 16;
8778 let p = parse_nl_text(&bounded_deep_shared_cse_nl(m, 20)).expect("parse");
8779
8780 let mut hybrid = NlTnlp::new(p.clone());
8781 let info = hybrid.get_nlp_info().unwrap();
8782 let nnz = info.nnz_h_lag as usize;
8783 assert!(
8784 hybrid
8785 .con_hybrid
8786 .as_ref()
8787 .expect("shared CSE must build the hybrid tape")
8788 .use_for_hess,
8789 "a 40-op body shared by 16 rows is well past the op-ratio gate"
8790 );
8791
8792 let mut flat = NlTnlp::new(p);
8793 flat.get_nlp_info().unwrap();
8794 flat.con_hybrid = None;
8795
8796 let lam: Vec<f64> = (0..m).map(|k| 0.25 + 0.125 * k as f64).collect();
8797 for scale in [1.0_f64, -0.7, 2.5] {
8798 let x: Vec<f64> = (0..info.n as usize)
8799 .map(|j| scale * (0.2 + 0.03 * j as f64))
8800 .collect();
8801 // Run the hybrid Jacobian first, the order a real solve
8802 // iteration uses. Its `gradient_summand` sweeps leave the
8803 // *Jacobian's* prelude adjoint arena dirty; the Hessian's
8804 // accumulators must be its own buffers with the all-zero-
8805 // between-colors invariant, or this seeds `eval_h` with a
8806 // stale row gradient (caught here).
8807 let mut jac = vec![0.0_f64; info.nnz_jac_g as usize];
8808 assert!(hybrid.eval_jac_g(
8809 Some(&x),
8810 true,
8811 SparsityRequest::Values { values: &mut jac }
8812 ));
8813 let mut hh = vec![0.0_f64; nnz];
8814 let mut hf = vec![0.0_f64; nnz];
8815 assert!(hybrid.eval_h(
8816 Some(&x),
8817 true,
8818 1.0,
8819 Some(&lam),
8820 true,
8821 SparsityRequest::Values { values: &mut hh }
8822 ));
8823 assert!(flat.eval_h(
8824 Some(&x),
8825 true,
8826 1.0,
8827 Some(&lam),
8828 true,
8829 SparsityRequest::Values { values: &mut hf }
8830 ));
8831 for k in 0..nnz {
8832 assert!(
8833 hh[k].is_finite() && hf[k].is_finite(),
8834 "non-finite Hessian entry {k} defeats the comparison"
8835 );
8836 let tol = 1e-12 * hf[k].abs().max(1.0);
8837 assert!(
8838 (hh[k] - hf[k]).abs() <= tol,
8839 "gate-on Hessian entry {k} at scale {scale}: hybrid {} vs flat {}",
8840 hh[k],
8841 hf[k]
8842 );
8843 }
8844 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
8845 }
8846 }
8847
8848 /// `.nl` text for two independent shared-CSE blocks of different widths:
8849 /// block A's body is `sin(x0 + … + x_{wide-1})`, block B's is
8850 /// `sin(x_wide + … )` over `narrow` variables, each feeding `rows` rows
8851 /// of the form `body * x_r`.
8852 ///
8853 /// The width difference is the point. A body summing `w` variables gives
8854 /// its rows a dense `w × w` Hessian block, so those `w` columns pairwise
8855 /// conflict and the coloring must spend `w` colors on them; the narrower
8856 /// block reuses the low colors. The surplus colors therefore belong to
8857 /// block A alone, and a per-color prelude walk over *both* bodies would
8858 /// be doing work for a body that color cannot reach.
8859 fn two_block_shared_cse_nl(wide: usize, narrow: usize, rows: usize) -> String {
8860 let nvars = wide + narrow;
8861 let m = 2 * rows;
8862 let n = nvars + m;
8863 // Block A rows touch `wide` body vars + 1 row var; block B rows
8864 // touch `narrow` + 1.
8865 let nzc = rows * (wide + 1) + rows * (narrow + 1);
8866 let mut s = String::new();
8867 s.push_str("g3 1 1 0\n");
8868 s.push_str(&format!(" {n} {m} 1 0 0 0\n"));
8869 s.push_str(&format!(" {m} 0\n 0 0\n"));
8870 s.push_str(&format!(" {n} 0 0\n"));
8871 s.push_str(" 0 0 0 1\n 0 0 0 0 0\n");
8872 s.push_str(&format!(" {nzc} {n}\n"));
8873 s.push_str(" 0 0\n 0 2 0 0 0\n");
8874 // Two bodies: V{n} over the first `wide` vars, V{n+1} over the next
8875 // `narrow`. `sin` of a left-nested sum: k terms need k-1 adds.
8876 for (b, (base, count)) in [(0, wide), (wide, narrow)].iter().enumerate() {
8877 s.push_str(&format!("V{} 0 0\n", n + b));
8878 s.push_str("o41\n");
8879 for _ in 0..count - 1 {
8880 s.push_str("o0\n");
8881 }
8882 for j in 0..*count {
8883 s.push_str(&format!("v{}\n", base + j));
8884 }
8885 }
8886 // Rows: body_b * x_{rowvar}.
8887 for i in 0..m {
8888 let b = i / rows;
8889 s.push_str(&format!("C{i}\no2\nv{}\nv{}\n", n + b, nvars + i));
8890 }
8891 s.push_str("O0 0\nn0\n");
8892 s.push_str(&format!("x{n}\n"));
8893 for j in 0..n {
8894 s.push_str(&format!("{j} {}\n", 0.2 + 0.01 * j as f64));
8895 }
8896 s.push_str("r\n");
8897 for _ in 0..m {
8898 s.push_str("2 0\n");
8899 }
8900 s.push_str("b\n");
8901 for _ in 0..n {
8902 s.push_str("3\n");
8903 }
8904 // Cumulative Jacobian column counts for the first n-1 columns.
8905 s.push_str(&format!("k{}\n", n - 1));
8906 let mut acc = 0;
8907 for j in 0..n - 1 {
8908 acc += if j < nvars { rows } else { 1 };
8909 s.push_str(&format!("{acc}\n"));
8910 }
8911 for i in 0..m {
8912 let (base, count) = if i < rows { (0, wide) } else { (wide, narrow) };
8913 s.push_str(&format!("J{i} {}\n", count + 1));
8914 let mut cols: Vec<usize> = (base..base + count).collect();
8915 cols.push(nvars + i);
8916 cols.sort_unstable();
8917 for c in cols {
8918 s.push_str(&format!("{c} 0.0\n"));
8919 }
8920 }
8921 s.push_str(&format!("G0 {n}\n"));
8922 for j in 0..n {
8923 s.push_str(&format!("{j} 0.0\n"));
8924 }
8925 s
8926 }
8927
8928 /// Both prelude sweeps run once per color, so iterating the whole prelude
8929 /// each time would cost `n_colors × |prelude|` where the op-ratio gate
8930 /// assumes `|prelude|` — a cost the gate cannot see (PR #559 review).
8931 /// `eval_h` instead walks the union of that color's summands'
8932 /// `prelude_reach`.
8933 ///
8934 /// What this can and cannot pin is worth being exact about, because the
8935 /// change is pure performance: walking the whole prelude per color
8936 /// computes the *same* Hessian, so no assertion on output values can
8937 /// detect it, and a timing assertion would be flaky. So this asserts the
8938 /// two things that are checkable — that the table the sweeps iterate is
8939 /// strictly smaller than the naive `n_colors × |prelude|` walk on a model
8940 /// where colors genuinely reach different bodies, and that each reach list
8941 /// is ascending and operand-closed, the invariants that make the narrowed
8942 /// walk safe — plus agreement with the flat tapes under narrowing.
8943 #[test]
8944 fn per_color_prelude_reach_skips_bodies_the_color_cannot_touch() {
8945 let p = parse_nl_text(&two_block_shared_cse_nl(6, 2, 3)).expect("parse");
8946 let mut hybrid = NlTnlp::new(p.clone());
8947 let info = hybrid.get_nlp_info().unwrap();
8948 let nnz = info.nnz_h_lag as usize;
8949 let m = info.m as usize;
8950
8951 {
8952 let h = hybrid
8953 .con_hybrid
8954 .as_mut()
8955 .expect("two shared CSE bodies must build the hybrid tape");
8956 h.use_for_hess = true;
8957
8958 let np = h.tape.n_prelude_ops();
8959 let n_colors = h.hess_color_reach_off.len() - 1;
8960 let total: usize = h.hess_color_reach.len();
8961 assert!(np > 0 && n_colors > 1, "np={np} n_colors={n_colors}");
8962 assert!(
8963 total < n_colors * np,
8964 "per-color reach must be strictly smaller than walking the whole \
8965 prelude per color: Σ|reach_c| = {total}, n_colors × |prelude| = {}",
8966 n_colors * np
8967 );
8968 // Every reach list must be ascending and operand-closed, which is
8969 // what makes the narrowed walk safe.
8970 for c in 0..n_colors {
8971 let r =
8972 &h.hess_color_reach[h.hess_color_reach_off[c]..h.hess_color_reach_off[c + 1]];
8973 assert!(
8974 r.windows(2).all(|w| w[0] < w[1]),
8975 "color {c} reach is not strictly ascending"
8976 );
8977 let member: std::collections::HashSet<u32> = r.iter().copied().collect();
8978 for &i in r {
8979 let (a, b) = crate::nl_tape::op_operands(&h.tape.prelude[i as usize]);
8980 for opnd in [a, b].into_iter().flatten() {
8981 assert!(
8982 member.contains(&(opnd as u32)),
8983 "color {c}: slot {i}'s operand {opnd} is missing from its reach"
8984 );
8985 }
8986 }
8987 }
8988 }
8989
8990 let mut flat = NlTnlp::new(p);
8991 flat.get_nlp_info().unwrap();
8992 flat.con_hybrid = None;
8993
8994 let lam: Vec<f64> = (0..m).map(|k| 0.3 + 0.2 * k as f64).collect();
8995 for scale in [1.0_f64, -0.6] {
8996 let x: Vec<f64> = (0..info.n as usize)
8997 .map(|j| scale * (0.15 + 0.02 * j as f64))
8998 .collect();
8999 let mut hh = vec![0.0_f64; nnz];
9000 let mut hf = vec![0.0_f64; nnz];
9001 assert!(hybrid.eval_h(
9002 Some(&x),
9003 true,
9004 1.0,
9005 Some(&lam),
9006 true,
9007 SparsityRequest::Values { values: &mut hh }
9008 ));
9009 assert!(flat.eval_h(
9010 Some(&x),
9011 true,
9012 1.0,
9013 Some(&lam),
9014 true,
9015 SparsityRequest::Values { values: &mut hf }
9016 ));
9017 for k in 0..nnz {
9018 let tol = 1e-12 * hf[k].abs().max(1.0);
9019 assert!(
9020 (hh[k] - hf[k]).abs() <= tol,
9021 "narrowed-reach Hessian entry {k} at scale {scale}: \
9022 hybrid {} vs flat {}",
9023 hh[k],
9024 hf[k]
9025 );
9026 }
9027 assert!(hh.iter().any(|v| *v != 0.0), "all-zero Hessian is no test");
9028 }
9029 }
9030
9031 /// The Hessian gate is off for a model whose shared bodies are small —
9032 /// below the ratio where the shared prelude sweeps pay for the hybrid
9033 /// traversal's per-op overhead — leaving `eval_h` on the flat path
9034 /// (which the gate keeps bit-identical for such models by definition).
9035 #[test]
9036 fn a_small_shared_body_leaves_the_hessian_on_the_flat_path() {
9037 let p = parse_nl_text(&shared_cse_nl("o2\nv3\nv2")).expect("parse");
9038 let mut t = NlTnlp::new(p);
9039 t.get_nlp_info().unwrap();
9040 let h = t.con_hybrid.as_ref().expect("hybrid built for eval_g");
9041 assert!(
9042 !h.use_for_hess,
9043 "a 3-row model with a 2-term CSE is below the Hessian op-ratio gate"
9044 );
9045 }
9046
9047 /// A CSE referenced from several constraints is evaluated once per
9048 /// `eval_g` via the shared prelude instead of once per reference. The
9049 /// values must be bit-identical to the flat per-summand `Tape` path,
9050 /// which is what makes the optimization safe to apply unconditionally.
9051 #[test]
9052 fn shared_cse_constraint_tape_matches_flat_tape_bit_for_bit() {
9053 let nl = shared_cse_nl("o2\nv3\nv2");
9054 let p = parse_nl_text(&nl).expect("parse shared-CSE model");
9055 let mut hybrid = NlTnlp::new(p.clone());
9056 hybrid.get_nlp_info().unwrap();
9057 let h = hybrid
9058 .con_hybrid
9059 .as_ref()
9060 .expect("CSE shared by 3 constraints must take the hybrid path");
9061 assert!(
9062 h.tape.n_prelude_ops() > 0,
9063 "shared CSE body must land in the prelude"
9064 );
9065
9066 // Same model with the hybrid path switched off: the reference.
9067 let mut flat = NlTnlp::new(p);
9068 flat.get_nlp_info().unwrap();
9069 flat.con_hybrid = None;
9070
9071 for x in [[1.0, 1.0, 1.0], [-2.0, 0.5, 3.0], [0.0, -1.5, -0.25]] {
9072 let mut gh = [0.0_f64; 3];
9073 let mut gf = [0.0_f64; 3];
9074 assert!(hybrid.eval_g(&x, true, &mut gh));
9075 assert!(flat.eval_g(&x, true, &mut gf));
9076 // V3 = 2 x0 + 3 x1.
9077 let s = 2.0 * x[0] + 3.0 * x[1];
9078 let want = [s * s, s * s * s + x[2], s * x[2]];
9079 for i in 0..3 {
9080 assert_eq!(gh[i], gf[i], "row {i} differs from the flat tape at {x:?}");
9081 assert!(
9082 (gh[i] - want[i]).abs() < 1e-9,
9083 "row {i}: got {}, want {}",
9084 gh[i],
9085 want[i]
9086 );
9087 }
9088 }
9089 }
9090
9091 /// `HybridTape::build_multi` *panics* on comparisons, AND/OR/NOT,
9092 /// if-then-else, min/max lists and external funcalls, so `eval_g` may only
9093 /// take that path after `hybrid_supported` clears the model. Here a
9094 /// min-list in one constraint has to disable it for the whole block —
9095 /// falling back, not panicking.
9096 #[test]
9097 fn unsupported_opcode_falls_back_to_the_flat_tape() {
9098 let nl = shared_cse_nl("o11\n2\nv3\nv2");
9099 let p = parse_nl_text(&nl).expect("parse min-list model");
9100 let mut tnlp = NlTnlp::new(p);
9101 tnlp.get_nlp_info().unwrap();
9102 assert!(
9103 tnlp.con_hybrid.is_none(),
9104 "a min-list anywhere in the constraint block must disable the hybrid path"
9105 );
9106 let mut g = [0.0_f64; 3];
9107 assert!(tnlp.eval_g(&[-2.0, 0.5, 3.0], true, &mut g));
9108 let s = 2.0 * -2.0 + 3.0 * 0.5; // -2.5
9109 assert!((g[0] - s * s).abs() < 1e-9);
9110 assert!((g[2] - s.min(3.0)).abs() < 1e-9, "min(V3, x2) = {}", g[2]);
9111 }
9112
9113 // ---- In-memory construction + HVP (issue #469) --------------------
9114
9115 fn v(i: usize) -> Expr {
9116 Expr::Var(i)
9117 }
9118
9119 fn c(x: Number) -> Expr {
9120 Expr::Const(x)
9121 }
9122
9123 fn bin(op: BinOp, a: Expr, b: Expr) -> Expr {
9124 Expr::Binary(op, Box::new(a), Box::new(b))
9125 }
9126
9127 fn un(op: UnaryOp, a: Expr) -> Expr {
9128 Expr::Unary(op, Box::new(a))
9129 }
9130
9131 /// `NlProblemParts` for an `n`-variable, unbounded model.
9132 fn parts(n: usize, objective: Expr, constraints: Vec<Expr>) -> NlProblemParts {
9133 let m = constraints.len();
9134 NlProblemParts {
9135 minimize: true,
9136 objective,
9137 obj_constant: 0.0,
9138 constraints,
9139 x_l: vec![-1e19; n],
9140 x_u: vec![1e19; n],
9141 x0: vec![0.0; n],
9142 g_l: vec![-1e19; m],
9143 g_u: vec![1e19; m],
9144 var_names: Vec::new(),
9145 con_names: Vec::new(),
9146 }
9147 }
9148
9149 /// A model built from expressions evaluates exactly like a parsed one:
9150 /// objective, gradient, constraints, and Jacobian all come from the
9151 /// same tape, with no `.nl` text in the loop.
9152 #[test]
9153 fn from_expressions_builds_evaluable_problem() {
9154 // min (1-x0)^2 + 100*(x1 - x0^2)^2 s.t. x0^2 + x1^2 <= 2
9155 let rosen = bin(
9156 BinOp::Add,
9157 bin(BinOp::Pow, bin(BinOp::Sub, c(1.0), v(0)), c(2.0)),
9158 bin(
9159 BinOp::Mul,
9160 c(100.0),
9161 bin(
9162 BinOp::Pow,
9163 bin(BinOp::Sub, v(1), bin(BinOp::Pow, v(0), c(2.0))),
9164 c(2.0),
9165 ),
9166 ),
9167 );
9168 let circle = bin(
9169 BinOp::Add,
9170 bin(BinOp::Pow, v(0), c(2.0)),
9171 bin(BinOp::Pow, v(1), c(2.0)),
9172 );
9173
9174 let mut p = parts(2, rosen, vec![circle]);
9175 p.g_l = vec![0.0];
9176 p.g_u = vec![2.0];
9177 p.x0 = vec![-1.2, 1.0];
9178 p.var_names = names(&["x", "y"]);
9179 p.con_names = names(&["circle"]);
9180
9181 let prob = NlProblem::from_expressions(p).expect("build");
9182 assert_eq!((prob.n, prob.m), (2, 1));
9183 assert_eq!(prob.var_names, names(&["x", "y"]));
9184
9185 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9186 t.get_nlp_info().unwrap();
9187
9188 // f(-1.2, 1) = (2.2)^2 + 100*(1 - 1.44)^2 = 4.84 + 19.36 = 24.2
9189 let f = t.eval_f(&[-1.2, 1.0], true).unwrap();
9190 assert!((f - 24.2).abs() < 1e-10, "f = {f}");
9191
9192 // ∇f = (-2(1-x0) - 400 x0 (x1 - x0^2), 200 (x1 - x0^2))
9193 // = (4.4 + 480*(-0.44)... ) — computed below rather than
9194 // transcribed, so the check is the formula, not an editor.
9195 let (x0, x1) = (-1.2, 1.0);
9196 let want = [
9197 -2.0 * (1.0 - x0) - 400.0 * x0 * (x1 - x0 * x0),
9198 200.0 * (x1 - x0 * x0),
9199 ];
9200 let mut g = [0.0_f64; 2];
9201 assert!(t.eval_grad_f(&[x0, x1], true, &mut g));
9202 for j in 0..2 {
9203 assert!((g[j] - want[j]).abs() < 1e-8, "g[{j}] = {} ", g[j]);
9204 }
9205
9206 // g(x) = x0^2 + x1^2 = 2.44
9207 let mut gv = [0.0_f64; 1];
9208 assert!(t.eval_g(&[x0, x1], true, &mut gv));
9209 assert!((gv[0] - 2.44).abs() < 1e-10, "g = {}", gv[0]);
9210 }
9211
9212 /// `min`/`max`, `atan2`, and `erf` all reach the evaluator through
9213 /// this path. None of the three survives a `.nl` round trip in a
9214 /// typical frontend — `atan2` has no two-argument funcall path,
9215 /// `min`/`max` force a DNLP model type, and AMPL has no `erf` opcode
9216 /// at all — which is the reason the in-memory door exists.
9217 #[test]
9218 fn from_expressions_carries_ops_nl_cannot_express() {
9219 let obj = Expr::Sum(vec![
9220 bin(BinOp::Atan2, v(0), v(1)),
9221 Expr::MinList(vec![v(0), v(1)]),
9222 Expr::MaxList(vec![v(0), v(1)]),
9223 un(UnaryOp::Erf, v(0)),
9224 ]);
9225 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
9226 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9227 t.get_nlp_info().unwrap();
9228
9229 let x: [Number; 2] = [0.8, 1.5];
9230 // atan2 + min + max + erf; min+max == x0+x1 for any pair.
9231 let want = x[0].atan2(x[1]) + x[0] + x[1] + crate::nl_tape::erf(x[0]);
9232 let f = t.eval_f(&x, true).unwrap();
9233 assert!((f - want).abs() < 1e-12, "f = {f}, want {want}");
9234 }
9235
9236 /// A `Var` index past `n` would be an out-of-bounds read in the
9237 /// tape's forward sweep. It has to be caught at construction, while
9238 /// it is still a diagnosable user error.
9239 #[test]
9240 fn from_expressions_rejects_out_of_range_var() {
9241 let err = NlProblem::from_expressions(parts(2, v(5), Vec::new()))
9242 .expect_err("Var(5) with n = 2 must be rejected");
9243 assert!(err.contains("Var(5)"), "{err}");
9244
9245 let err = NlProblem::from_expressions(parts(2, c(0.0), vec![v(2)]))
9246 .expect_err("constraint Var(2) with n = 2 must be rejected");
9247 assert!(err.contains("constraint 0"), "{err}");
9248
9249 // Length mismatches are errors too, not panics.
9250 let mut p = parts(2, c(0.0), Vec::new());
9251 p.x0 = vec![0.0; 3];
9252 let err = NlProblem::from_expressions(p).expect_err("x0 length must be checked");
9253 assert!(err.contains("x0"), "{err}");
9254 }
9255
9256 /// An out-of-range `Var` must be caught wherever it hides, not just at
9257 /// the top level — inside a `Cse` body, a nested `Cse`, a `Cond`
9258 /// branch, and a funcall argument all reach the same forward sweep.
9259 #[test]
9260 fn from_expressions_finds_out_of_range_vars_in_every_position() {
9261 let inner_cse = Arc::new(v(7));
9262 let cases: Vec<(&str, Expr)> = vec![
9263 ("bare", v(7)),
9264 ("cse", Expr::Cse(Arc::new(v(7)))),
9265 ("nested cse", Expr::Cse(Arc::new(Expr::Cse(inner_cse)))),
9266 (
9267 "cond branch",
9268 Expr::Cond {
9269 cond: Box::new(c(1.0)),
9270 then_: Box::new(v(7)),
9271 else_: Box::new(c(0.0)),
9272 },
9273 ),
9274 ("min list", Expr::MinList(vec![c(0.0), v(7)])),
9275 (
9276 "sum",
9277 Expr::Sum(vec![c(0.0), bin(BinOp::Mul, c(2.0), v(7))]),
9278 ),
9279 ];
9280 for (label, e) in cases {
9281 let err = NlProblem::from_expressions(parts(2, e, Vec::new()))
9282 .err()
9283 .unwrap_or_else(|| panic!("{label}: Var(7) with n = 2 should be rejected"));
9284 assert!(err.contains("Var(7)"), "{label}: {err}");
9285 }
9286 }
9287
9288 /// A balanced share-DAG: each level is one `Cse` whose body
9289 /// references the level below twice. Depth `d` is `d` distinct nodes
9290 /// but `2^d` paths, so any walk that re-enters a shared body per
9291 /// occurrence is Θ(2^d).
9292 fn share_dag(depth: usize) -> Expr {
9293 let mut e = v(0);
9294 for _ in 0..depth {
9295 let shared = Arc::new(e);
9296 e = bin(
9297 BinOp::Add,
9298 Expr::Cse(Arc::clone(&shared)),
9299 Expr::Cse(shared),
9300 );
9301 }
9302 e
9303 }
9304
9305 /// The walks over a shared DAG must be memoized, not exponential.
9306 ///
9307 /// At depth 30 an unmemoized walk is ~10^9 node visits — this test
9308 /// does not "fail" so much as never finish, which is exactly the
9309 /// signal. `from_expressions` is the door that makes such a DAG
9310 /// trivially constructible, but the blowup was reachable through
9311 /// `collect_vars` (which presolve calls on every solve, via
9312 /// `get_variables_linearity`) and `collect_funcall_ids` (which
9313 /// `NlTnlp::try_new` runs over every row).
9314 #[test]
9315 fn shared_dag_walks_are_memoized_not_exponential() {
9316 const DEPTH: usize = 30;
9317 let e = share_dag(DEPTH);
9318
9319 let mut vars = BTreeSet::new();
9320 collect_vars(&e, &mut vars);
9321 assert_eq!(vars.iter().copied().collect::<Vec<_>>(), vec![0]);
9322
9323 let mut ids = BTreeSet::new();
9324 super::super::nl_external::collect_funcall_ids(&e, &mut ids);
9325 assert!(ids.is_empty());
9326
9327 // The whole build path, end to end: validation, tape construction,
9328 // and the linearity metadata presolve consumes.
9329 let prob = NlProblem::from_expressions(parts(1, e, Vec::new())).expect("build");
9330 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9331 t.get_nlp_info().unwrap();
9332 let mut lin = vec![Linearity::Linear; 1];
9333 assert!(t.get_variables_linearity(&mut lin));
9334 }
9335
9336 /// `from_expressions` cannot carry AMPL imported functions — there is
9337 /// nowhere to put the `F`-segment declarations that bind a funcall id
9338 /// to a library — so a `Funcall` must be refused up front. Accepting it
9339 /// produces "AMPLFUNC is not set", which the user cannot act on:
9340 /// setting `AMPLFUNC` only moves the failure to "no F<id> declaration".
9341 #[test]
9342 fn from_expressions_rejects_imported_function_calls() {
9343 let call = Expr::Funcall {
9344 id: 0,
9345 args: vec![FuncallArg::Real(v(0))],
9346 };
9347 let err = NlProblem::from_expressions(parts(1, call.clone(), Vec::new()))
9348 .expect_err("a Funcall must be rejected, not deferred to AMPLFUNC");
9349 assert!(err.contains("imported function"), "{err}");
9350 assert!(
9351 err.contains("read_nl") || err.contains("parse_nl_text"),
9352 "the error must point at the paths that do support externals: {err}"
9353 );
9354
9355 // Also when buried in a constraint, behind a Cse.
9356 let buried = Expr::Cse(Arc::new(Expr::Sum(vec![c(1.0), call])));
9357 let err = NlProblem::from_expressions(parts(1, c(0.0), vec![buried]))
9358 .expect_err("a buried Funcall must be rejected too");
9359 assert!(err.contains("constraint 0"), "{err}");
9360 }
9361
9362 /// The matrix-free HVP must reproduce `eval_h`'s Hessian exactly —
9363 /// same tapes, same weights, one seed instead of a color sweep. The
9364 /// objective and both constraints are chosen with cross terms so the
9365 /// off-diagonal blocks actually carry signal.
9366 #[test]
9367 fn hessian_vector_product_matches_dense_hessian() {
9368 let obj = Expr::Sum(vec![
9369 bin(BinOp::Mul, v(0), bin(BinOp::Mul, v(1), v(2))),
9370 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
9371 un(UnaryOp::Erf, v(2)),
9372 ]);
9373 let cons = vec![
9374 bin(
9375 BinOp::Add,
9376 bin(BinOp::Pow, v(0), c(2.0)),
9377 un(UnaryOp::Sin, v(2)),
9378 ),
9379 bin(BinOp::Mul, v(1), v(2)),
9380 ];
9381 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
9382 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9383 let info = t.get_nlp_info().unwrap();
9384
9385 let x = [0.3, -0.7, 1.1];
9386 let lam = [0.5, -1.25];
9387 let obj_factor = 2.0;
9388
9389 // Dense Hessian from the sparse lower triangle.
9390 let nnz = info.nnz_h_lag as usize;
9391 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
9392 assert!(t.eval_h(
9393 None,
9394 false,
9395 1.0,
9396 None,
9397 false,
9398 SparsityRequest::Structure {
9399 irow: &mut irow,
9400 jcol: &mut jcol
9401 }
9402 ));
9403 let mut hvals = vec![0.0_f64; nnz];
9404 assert!(t.eval_h(
9405 Some(&x),
9406 true,
9407 obj_factor,
9408 Some(&lam),
9409 true,
9410 SparsityRequest::Values { values: &mut hvals }
9411 ));
9412 let mut dense = [[0.0_f64; 3]; 3];
9413 for k in 0..nnz {
9414 let (i, j) = (irow[k] as usize, jcol[k] as usize);
9415 dense[i][j] += hvals[k];
9416 if i != j {
9417 dense[j][i] += hvals[k];
9418 }
9419 }
9420
9421 // Each unit seed recovers a column; a mixed seed catches an HVP
9422 // that only happens to be right on the basis vectors.
9423 let seeds: [[Number; 3]; 4] = [
9424 [1.0, 0.0, 0.0],
9425 [0.0, 1.0, 0.0],
9426 [0.0, 0.0, 1.0],
9427 [0.4, -1.3, 2.0],
9428 ];
9429 let mut out = vec![0.0; 3];
9430 for s in &seeds {
9431 t.hessian_vector_product(&x, s, obj_factor, Some(&lam), &mut out)
9432 .expect("hvp");
9433 for i in 0..3 {
9434 let want: Number = (0..3).map(|j| dense[i][j] * s[j]).sum();
9435 assert!(
9436 (out[i] - want).abs() < 1e-9,
9437 "seed {s:?} row {i}: hvp={:.9e} dense={want:.9e}",
9438 out[i]
9439 );
9440 }
9441 }
9442 }
9443
9444 /// `lam = None` is the objective block alone, and `out` is
9445 /// overwritten (not accumulated) so a reused buffer is safe.
9446 #[test]
9447 fn hessian_vector_product_defaults_and_validation() {
9448 // f = x0^2 + 3 x0 x1 -> ∇²f = [[2, 3], [3, 0]]
9449 let obj = bin(
9450 BinOp::Add,
9451 bin(BinOp::Pow, v(0), c(2.0)),
9452 bin(BinOp::Mul, c(3.0), bin(BinOp::Mul, v(0), v(1))),
9453 );
9454 let prob = NlProblem::from_expressions(parts(2, obj, Vec::new())).expect("build");
9455 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9456 t.get_nlp_info().unwrap();
9457
9458 let mut out = vec![7.0, -7.0]; // dirty buffer
9459 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut out)
9460 .expect("hvp");
9461 assert!((out[0] - 5.0).abs() < 1e-12, "out = {out:?}");
9462 assert!((out[1] - 3.0).abs() < 1e-12, "out = {out:?}");
9463
9464 // obj_factor scales linearly.
9465 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], -2.0, None, &mut out)
9466 .expect("hvp");
9467 assert!((out[0] + 10.0).abs() < 1e-12, "out = {out:?}");
9468
9469 // Length mismatches are errors, not panics or silent truncation.
9470 let mut short = vec![0.0; 1];
9471 assert!(
9472 t.hessian_vector_product(&[0.5, 2.0], &[1.0, 1.0], 1.0, None, &mut short)
9473 .is_err()
9474 );
9475 assert!(
9476 t.hessian_vector_product(&[0.5], &[1.0, 1.0], 1.0, None, &mut out)
9477 .is_err()
9478 );
9479 assert!(
9480 t.hessian_vector_product(&[0.5, 2.0], &[1.0], 1.0, None, &mut out)
9481 .is_err()
9482 );
9483 }
9484
9485 /// A chain objective `Σ (x_i·x_{i+1})² + exp(x_i)` has a tridiagonal
9486 /// Hessian — the sparse shape an IPM actually meets. The block HVP has
9487 /// to reproduce it column for column, including the structural zeros:
9488 /// a bug that leaked coupling between non-adjacent variables would
9489 /// show up here and nowhere in a small dense test.
9490 #[test]
9491 fn hessian_vector_products_on_a_sparse_hessian() {
9492 const N: usize = 8;
9493 let mut terms = Vec::new();
9494 for i in 0..N - 1 {
9495 terms.push(bin(BinOp::Pow, bin(BinOp::Mul, v(i), v(i + 1)), c(2.0)));
9496 }
9497 for i in 0..N {
9498 terms.push(un(UnaryOp::Exp, v(i)));
9499 }
9500 let prob =
9501 NlProblem::from_expressions(parts(N, Expr::Sum(terms), Vec::new())).expect("build");
9502 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9503 let info = t.get_nlp_info().unwrap();
9504
9505 // Tridiagonal lower triangle: N diagonal + (N-1) sub-diagonal.
9506 assert_eq!(
9507 info.nnz_h_lag as usize,
9508 2 * N - 1,
9509 "chain objective should give a tridiagonal Hessian, not a dense one"
9510 );
9511
9512 let x: Vec<Number> = (0..N).map(|i| 0.2 + 0.1 * i as Number).collect();
9513
9514 // Densify the sparse triangle for the reference.
9515 let nnz = info.nnz_h_lag as usize;
9516 let (mut irow, mut jcol) = (vec![0_i32; nnz], vec![0_i32; nnz]);
9517 assert!(t.eval_h(
9518 None,
9519 false,
9520 1.0,
9521 None,
9522 false,
9523 SparsityRequest::Structure {
9524 irow: &mut irow,
9525 jcol: &mut jcol
9526 }
9527 ));
9528 let mut hvals = vec![0.0; nnz];
9529 assert!(t.eval_h(
9530 Some(&x),
9531 true,
9532 1.0,
9533 None,
9534 true,
9535 SparsityRequest::Values { values: &mut hvals }
9536 ));
9537 let mut dense = vec![vec![0.0; N]; N];
9538 for k in 0..nnz {
9539 let (i, j) = (irow[k] as usize, jcol[k] as usize);
9540 dense[i][j] += hvals[k];
9541 if i != j {
9542 dense[j][i] += hvals[k];
9543 }
9544 }
9545
9546 // One block of N unit seeds recovers the whole matrix — the
9547 // "densify via HVPs" path, in one call.
9548 let mut seeds = vec![0.0; N * N];
9549 for cc in 0..N {
9550 seeds[cc * N + cc] = 1.0;
9551 }
9552 let mut out = vec![0.0; N * N];
9553 t.hessian_vector_products(&x, &seeds, N, 1.0, None, &mut out)
9554 .expect("block hvp");
9555 for cc in 0..N {
9556 for i in 0..N {
9557 assert!(
9558 (out[cc * N + i] - dense[i][cc]).abs() < 1e-9,
9559 "H[{i},{cc}]: block={:.9e} sparse={:.9e}",
9560 out[cc * N + i],
9561 dense[i][cc]
9562 );
9563 }
9564 }
9565 }
9566
9567 /// The block form must agree with `k` separate single-vector calls
9568 /// (it shares one forward sweep across directions, so a bug there
9569 /// would show as a per-direction discrepancy), and must skip an
9570 /// all-zero direction without disturbing its neighbours.
9571 #[test]
9572 fn hessian_vector_products_match_repeated_single_calls() {
9573 let obj = Expr::Sum(vec![
9574 un(UnaryOp::Exp, bin(BinOp::Mul, v(0), v(1))),
9575 bin(BinOp::Pow, v(2), c(4.0)),
9576 bin(BinOp::Mul, v(0), v(2)),
9577 ]);
9578 let cons = vec![bin(BinOp::Mul, v(1), v(2))];
9579 let prob = NlProblem::from_expressions(parts(3, obj, cons)).expect("build");
9580 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9581 t.get_nlp_info().unwrap();
9582
9583 let x = [0.4, -0.6, 1.3];
9584 let lam = [0.75];
9585 let cols: [[Number; 3]; 4] = [
9586 [1.0, 2.0, -3.0],
9587 [0.0, 0.0, 0.0], // the skipped direction
9588 [0.5, 0.0, 0.0],
9589 [-1.0, 1.0, 1.0],
9590 ];
9591
9592 let mut block = vec![0.0; 3 * cols.len()];
9593 let flat: Vec<Number> = cols.iter().flat_map(|c| c.iter().copied()).collect();
9594 t.hessian_vector_products(&x, &flat, cols.len(), 1.0, Some(&lam), &mut block)
9595 .expect("block hvp");
9596
9597 for (c, col) in cols.iter().enumerate() {
9598 let mut single = vec![0.0; 3];
9599 t.hessian_vector_product(&x, col, 1.0, Some(&lam), &mut single)
9600 .expect("single hvp");
9601 for i in 0..3 {
9602 assert!(
9603 (block[c * 3 + i] - single[i]).abs() < 1e-12,
9604 "direction {c} row {i}: block={:.12e} single={:.12e}",
9605 block[c * 3 + i],
9606 single[i]
9607 );
9608 }
9609 }
9610 // The zero direction really is zero, not stale scratch.
9611 assert!(block[3..6].iter().all(|&z| z == 0.0), "{block:?}");
9612 }
9613
9614 /// `k = 0` is a legal empty block, and the length checks scale with
9615 /// `k` rather than assuming a single direction.
9616 #[test]
9617 fn hessian_vector_products_validate_block_shape() {
9618 let prob = NlProblem::from_expressions(parts(2, bin(BinOp::Pow, v(0), c(2.0)), Vec::new()))
9619 .expect("build");
9620 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9621 t.get_nlp_info().unwrap();
9622
9623 let mut empty: Vec<Number> = Vec::new();
9624 assert!(
9625 t.hessian_vector_products(&[1.0, 1.0], &[], 0, 1.0, None, &mut empty)
9626 .is_ok()
9627 );
9628
9629 // v sized for one direction while k says two.
9630 let mut out = vec![0.0; 4];
9631 assert!(
9632 t.hessian_vector_products(&[1.0, 1.0], &[1.0, 1.0], 2, 1.0, None, &mut out)
9633 .is_err()
9634 );
9635 // out sized for one direction while k says two.
9636 let mut short = vec![0.0; 2];
9637 assert!(
9638 t.hessian_vector_products(&[1.0, 1.0], &[1.0; 4], 2, 1.0, None, &mut short)
9639 .is_err()
9640 );
9641 }
9642
9643 /// A `maximize` model's objective is negated by the evaluator, and
9644 /// the HVP has to agree with `eval_h` about that — otherwise a
9645 /// Hessian-free step would climb where the sparse path descends.
9646 #[test]
9647 fn hessian_vector_product_respects_maximize_sign() {
9648 let obj = bin(BinOp::Pow, v(0), c(2.0));
9649 let mut p = parts(1, obj, Vec::new());
9650 p.minimize = false;
9651 let prob = NlProblem::from_expressions(p).expect("build");
9652 let mut t = NlTnlp::try_new(prob).expect("tnlp");
9653 t.get_nlp_info().unwrap();
9654
9655 // max x0^2 is minimized as -x0^2, so ∇² = -2.
9656 let mut out = vec![0.0; 1];
9657 t.hessian_vector_product(&[1.0], &[1.0], 1.0, None, &mut out)
9658 .expect("hvp");
9659 assert!((out[0] + 2.0).abs() < 1e-12, "out = {out:?}");
9660 }
9661}