Solver Options
POUNCE accepts options the same way upstream Ipopt does. Option names
and semantics follow Ipopt’s, so an existing Ipopt options file or
KEY=VALUE invocation works unchanged.
Setting options
On the command line — append KEY=VALUE pairs after the input:
pounce problem.nl tol=1e-10 max_iter=500 print_level=8
From an options file — upstream ipopt.opt format (one name value
pair per line, # comments):
pounce problem.nl --options-file tuned.opt
pounce problem.nl option_file_name=tuned.opt # the Ipopt spelling; same thing
From an options file nobody named. With neither of the above, POUNCE
looks in the working directory for pounce.opt, then ipopt.opt, and
reads the first one it finds — the way ipopt picks up ./ipopt.opt:
printf 'max_iter 5\n' > ipopt.opt
pounce problem.nl # → Using option file "ipopt.opt".
The run says which file configured it (on the same sb gate as the
banner). If both default names are present, pounce.opt wins and the
other is named in a warning rather than left to look applied.
--no-options-file skips the lookup entirely — the escape hatch for a
directory holding an options file written for some other run.
Command-line KEY=VALUE pairs — and $pounce_options, AMPL’s
<solver>_options channel — override values loaded from the options
file, never the reverse.
An options file that was named but cannot be read is an error, not a shrug:
$ pounce problem.nl option_file_name=typo.opt
pounce: failed to load options file: options file "typo.opt" does not exist. …
Upstream opens a named file with a bare ifstream and reads nothing if
that fails, so a typo there runs at stock defaults without a word. That
silence is the one thing this path exists to remove
(#518): a run configured
entirely through an options file that quietly ran at defaults still
reported success, which invalidates any benchmark set up that way.
One upstream quirk is inherited: option_file_name set inside an
options file chains nowhere, because the file has already been chosen by
the time it is read. POUNCE warns about that rather than ignoring it.
Commonly used options
| Option | Meaning |
|---|---|
tol | Overall convergence tolerance on the KKT error. |
max_iter | Maximum number of outer iterations. |
print_level | Console verbosity, 0 (silent) – 12 (maximum debug). |
linear_solver | KKT linear-solver backend: feral (default) or ma57 (needs a --features ma57 build). Any other registered name is refused. See below. |
mu_strategy | Barrier-parameter update strategy (monotone / adaptive). |
solver_selection | Route LP/convex-QP to the specialized convex IPM. See LP/QP Routing. |
qp_presolve | Presolve on the convex LP/QP path, and on the conic (convex QCQP) path with the cone rows protected (yes / no, default yes; off unless asked for in Python pounce.qp.solve_qp). See LP/QP Routing. |
obj_scaling_factor | Constant multiplier on the objective; negative maximizes. See below. |
bound_relax_factor | Relaxation applied to variable/constraint bounds before the solve. Default 1e-8 on the NLP arm; the convex (LP/QP/conic) arms solve the model as declared unless you set this explicitly. See below. |
honor_original_bounds | Project the reported point back into the un-relaxed bounds (yes / no, default no). See below. |
For the full upstream option catalogue, see the Ipopt options reference; POUNCE reuses those names.
For scaling-specific options (nlp_scaling_method, target-gradient
overrides, linear_system_scaling), see the Scaling
reference page. For nonlinear bound tightening (presolve_fbbt,
fbbt_tol, fbbt_max_iter, fbbt_max_constraints), see the
FBBT reference page.
Options POUNCE does not implement
POUNCE’s option registry is a faithful port of Ipopt’s: every name Ipopt
registers is registered here, so an ipopt.opt written for Ipopt parses
unchanged. Registering an option is not the same as implementing it,
though — and for a long time setting an unimplemented one did nothing at
all, silently.
Options naming a feature POUNCE does not have now fail the solve, naming the option, the feature, and what to use instead:
$ pounce model.nl dependency_detector=mumps
pounce: `dependency_detector` configures linear-dependency detection on the
equality constraints, which pounce does not implement. It is registered so an
ipopt.opt written for Ipopt still parses, but setting it used to do nothing at
all — silently — so it is refused instead. Instead: pounce's presolve removes
structurally redundant rows; see `presolve`. Remove it to run.
The features in question: the Chen-Goldfarb (CG-penalty) / inexact-Newton
line search, derivative approximation by finite differences,
linear-dependency detection, the per-iteration NaN/Inf derivative check,
multiplier recalculation by least squares, least-square initialization of
all duals (least_square_init_duals), oracle-driven μ on the switch
into fixed mode (fixed_mu_oracle; POUNCE implements that option’s
default, average_compl), a selectable constraint-violation norm, magic
steps, bound replacement, the L-BFGS augmented-system variants, skipping
the finalize callback, the dynamic HSL loader, and suppress_all_output
/ debug_print_level.
Two line-search knobs joined that list with
#551. theta_min is the
CG-penalty acceptor’s threshold, not the filter’s — the filter line
search derives its own theta_min from theta_min_fact * max(1, θ₀), as
upstream does, and never takes it directly, so set theta_min_fact if
that is what you meant. alpha_for_y_tol configures only the
primal-and-full / dual-and-full multiplier-step rules, which POUNCE
does not have; alpha_for_y supports primal (the default),
bound-mult, min, max and full.
Four corrector knobs joined it too. corrector_type and its
safeguards skip_corr_if_neg_curv, skip_corr_in_monotone_mode and
corrector_compl_avrg_red_fact select and gate the corrector step Ipopt
tries inside the line search (FilterLSAcceptor::TryCorrector).
POUNCE’s line search takes no corrector trial at all. The
predictor-corrector POUNCE does implement is Mehrotra’s, applied to the
search-direction right-hand side: mehrotra_algorithm=yes is read and
honoured, and it also selects mu_strategy=adaptive and
mu_oracle=probing.
And three sub-capabilities of features that do run, where the
refusal message has to say so or it reads as “restoration is missing”:
expect_infeasible_problem_ctol / _ytol steer
IpBacktrackingLineSearch’s count_successive_shortened_steps_
machinery, which POUNCE does not have — the restoration phase itself
runs, and expect_infeasible_problem and
required_infeasibility_reduction are read;
resto_failure_feasibility_threshold asks for a threshold below which a
stopped restoration is reclassified as a failure, and POUNCE has no such
reclassification (max_resto_iter, below, is what bounds a restoration);
and limited_memory_special_for_resto=yes asks for the special
quasi-Newton update Ipopt dropped in Nov 2010 — L-BFGS runs in the
restoration sub-solve with the regular update, which is what upstream’s
own default no asks for.
The same rule applies one level down, to a single value of an option
that otherwise works. bound_mult_init_method is read and honoured, but
only its default constant is implemented; mu-based parses (an
ipopt.opt written for Ipopt still loads) and is then refused, because
serving it as constant would run a different initialization than the
one you asked for under the name you asked for. line_search_method=cg-penalty
is refused for the same reason
(#981): the CG-penalty
acceptor’s own knobs were already refused, but the value that selects it
used to run the plain penalty acceptor under the cg-penalty name,
with identical iterations and step characters. Use filter (the default)
or penalty.
And it applies per entry point, for an option whose feature exists but
sits somewhere this caller cannot reach. option_file_name is refused by
a library caller that reads no options file, and the convex qp_* knobs
(see LP/QP Routing) are refused
by one that cannot route a model to the convex engines. Both are
registered centrally, so they parse everywhere and the message can say
which surface honours them — rather than the caller getting Unknown option for a name that exists.
option_file_name was on that list until
#518 implemented it;
refusing an option is the cheap half of “implement it or fail loudly”,
and an entry leaves this table by getting the other half.
Two deliberate exceptions:
-
Setting an option to its registered default is allowed. A generated
ipopt.optspells out defaults, anddependency_detector=noneasks for nothing. Only a value that differs from the default is a request POUNCE cannot honour. -
Caching hints are checked, not trusted.
grad_f_constant,hessian_constant,jac_c_constantandjac_d_constanttell the solver a derivative does not change between iterations. Ipopt takes such a hint on faith and silently returns a wrong answer if it is false. POUNCE asks the model first, and there are three cases:- POUNCE proves the derivative constant — from an
.nlmodel’s own algebra — and reuses it across iterations whether or not you set the option. Setting it is harmless and unnecessary. - POUNCE proves the derivative is not constant and you set the
option anyway: the option is ignored, with a warning. A QCQP’s
∇²L = σQ₀ + Σᵢλᵢ Qᵢgenuinely varies with the multipliers, sohessian_constant=yesthere is not a hint but a false statement, and honouring it would trade a correct answer for a fast wrong one. - POUNCE cannot tell — every callback front end (the C interface,
the Python
Problemcallbacks, both GAMS links) hands POUNCE numbers rather than algebra — and your assertion is honoured on trust, exactly as upstream. “Unproved” is not “disproved”; overriding you here would be its own silent wrong answer.
POUNCE_DBG_CONSTDERIV=1prints which of the three fired for each of the four options. - POUNCE proves the derivative constant — from an
Options whose feature runs and whose value simply is not read yet are not in this category; they still solve, with the default in effect. Wiring those is tracked on #191 and #483.
Derivative checker
Wrong analytic derivatives are the most common cause of an NLP that
stalls, cycles, or converges to something that is not a solution — and
they are invisible from the iteration log. derivative_test compares
what your TNLP returns against finite differences at the (bound-projected)
starting point, before the solve:
pounce problem.nl derivative_test=first-order
| Option | Default | Effect |
|---|---|---|
derivative_test | none | none / first-order / second-order / only-second-order. |
derivative_test_perturbation | 1e-8 | Relative finite-difference step: `perturbation · max(1, |
derivative_test_tol | 1e-4 | Flag an entry when |analytic − fd| > tol · max(1, |fd|). |
derivative_test_first_index | -2 (all) | First variable for the first-order test; first constraint for the second-order one, where -1 is the objective’s Hessian. |
derivative_test_print_all | no | List every entry, not just the suspicious ones. |
first-order checks eval_grad_f and eval_jac_g; second-order adds
eval_h; only-second-order checks the Hessian alone. The Hessian is
checked one multiplier block at a time — obj_factor = 1, λ = 0 against
differences of eval_grad_f, then obj_factor = 0, λ = eⱼ against
differences of row j of eval_jac_g.
The report goes to stderr, and the verdict is also machine-readable: the
Python info["derivative_check"] and the solve report’s
statistics.derivative_check carry clean, the counts, the largest relative
error per derivative kind and the flagged entries (see
the report schema), so a notebook or a test can
assert on it instead of scraping stderr.
Entries that look wrong are marked *:
Derivative checker: first derivatives at the starting point (perturbation 1.0e-8, tolerance 1.0e-4).
* grad_f[ 1] = 3.5000000000000000e0 ~ 3.0000000119209290e0 [ 1.667e-1]
1 suspicious derivative(s) and 0 missing sparsity entrie(s) out of 6 checked (8 evaluations).
Two checks beyond upstream Ipopt’s, because both catch a class of bug no value-by-value comparison can:
- A Jacobian or Hessian entry whose finite difference is nonzero but
which the sparsity structure omits (
!in the report). A missing structural entry is not a wrong number — it is a derivative the solver can never see. - The perturbation is taken downward when stepping up would leave a
variable’s box, so a model using
sqrt,log, or1/xis not evaluated outside its own domain by the checker.
The test is advisory: it reports and the solve continues. It is written
to stderr, so it survives print_level=0 and never mixes into
--json-output’s stdout. It is slow — the second-order test costs
roughly (m+1)·n evaluations — so leave it off for production runs.
check_derivatives_for_naninfis a separate upstream option, for a per-iteration NaN/Inf guard, and is not implemented.
Choosing a linear solver
POUNCE implements two KKT backends:
feral— pure-Rust sparse symmetric indefinite solver. The effective default; no Fortran toolchain, no HSL licence.ma57— HSL MA57, available only in acargo build --features ma57build.
The option’s registered value list is a faithful port of upstream
Ipopt’s (ma27, ma77, ma86, ma97, mumps, pardiso,
pardisomkl, spral, wsmp, custom), so an ipopt.opt written for
Ipopt parses here unchanged. Selecting one of those fails the solve
with a message naming it. They used to fall through to FERAL silently,
which meant linear_solver=ma97 “worked” and a benchmark comparing
backends compared FERAL with itself.
The registered default is feral, which diverges from upstream’s
ma57 on purpose: a default has to name a solver the binary actually
contains. Under the upstream default a pure-Rust build advertised MA57 to
every print_user_options dump while running FERAL, and an
HSL-enabled build used MA57 without being asked. If you build
--features ma57 and want it, select it explicitly — that is the one
behavioural change here.
Not a failure: explicit ma57 on a build without the feature falls
back to FERAL and says so in the banner (FERAL (ma57 requested but not compiled)). That substitution is reported rather than hidden, and
failing a portable ipopt.opt over a build flag would cost more than it
buys.
The per-backend tuning options (ma97_scaling, mumps_pivtolmax,
pardiso_*, wsmp_*, spral_*, …) remain registered for the same
ipopt.opt-compatibility reason. They are unreachable now that their
backend cannot be selected, so setting one alongside options POUNCE does
read warns and solves — it does not fail the run:
$ pounce model.nl ma97_order=metis tol=1e-8
pounce: warning: `ma97_order` configures the HSL MA97 sparse symmetric linear
solver, which pounce does not implement, so it is ignored — as is every other
`ma97_*` option. pounce factors the KKT system with `feral` (pure Rust, the
default) or MA57 (`linear_solver=ma57`, in a `--features ma57` build); no
setting written for another backend transfers to either. The name is registered
so an `ipopt.opt` written for Ipopt still parses unchanged — which is why this
is a warning and not an error: the solve runs, and its result is unaffected.
A warning rather than a refusal, unlike the options above, because a
portable ipopt.opt routinely carries settings for several backends at
once so that one file runs everywhere. Refusing would fail that file over
knobs the run never touches — for a user who is not using MA97 and never
asked POUNCE to. One line is printed per backend family, listing the
options it saw, and only for a value that differs from the registered
default: a file that spells out defaults asks for nothing and gets
nothing said about it. pardisolib warns with the pardiso_* family;
hsllib is still refused, because POUNCE has an HSL backend (MA57) and
the refusal points you at --features ma57 rather than leaving you to
believe a library was loaded.
…unless they are all you set
That reasoning assumes the file has other business here. If the backend knobs are everything the run sets, nothing in the file survives, and warning-then-solving would answer “tune the linear solver” by tuning nothing and reporting success. That case is refused:
$ pounce model.nl ma97_order=metis
pounce: error: every option this run sets configures a linear-solver backend
pounce does not implement, so there is nothing left for it to act on. […] Set
`linear_solver=feral` (or `ma57`) if the defaults are what you want.
The rule in full:
| what the run sets | result |
|---|---|
| backend knobs only | error, exit 2 |
| backend knobs + any option POUNCE reads | warning, solve continues |
| backend knobs at their registered defaults | silent, solve continues |
| no backend knobs | silent, solve continues |
Two details worth knowing. The second row counts an option’s presence,
not whether you changed it — writing tol at its default is still a
statement about this solve, and it is enough to put you back on the
warning path. And option_file_name does not count as content: it says
where the options came from, not what to solve, so pointing at a
backend-only ipopt.opt is refused exactly as passing the same knobs on
the command line would be.
A file that selects the backend it tunes never reaches this: linear_solver=ma97
is refused on its own, as above.
MA57 backend knobs
Only relevant in a --features ma57 build running linear_solver=ma57;
see Installation. All of them
are the upstream Ipopt names with the upstream meanings, so an
ipopt.opt written for Ipopt-MA57 transfers unchanged.
| option | default | MA57 control | what it does |
|---|---|---|---|
ma57_pivtol | 1e-8 | CNTL(1) | Relative pivot threshold. Smaller pivots for sparsity, larger for stability. |
ma57_pivtolmax | 1e-4 | — | Ceiling the solver may raise ma57_pivtol to when it needs a more accurate solve. Must be ≥ ma57_pivtol. |
ma57_pre_alloc | 1.05 | — | Safety factor on the work-space MA57 suggests. Larger avoids a reallocation when the suggestion falls short. |
ma57_pivot_order | 5 | ICNTL(6) | Pivot-ordering strategy (0–5). |
ma57_automatic_scaling | no | ICNTL(15) | Let MA57 scale the matrix itself. See Scaling. |
ma57_block_size | 16 | ICNTL(11) | Block size for the Level-3 BLAS in MA57BD. |
ma57_node_amalgamation | 16 | ICNTL(12) | Node amalgamation parameter. |
ma57_small_pivot_flag | 0 | ICNTL(16) | 1 moves small pivots to the end of the factorization instead of using them — efficient on a highly rank-deficient matrix. |
ma57_print_level | 0 | — | MA57’s own printing: 0 silent, 1 errors, 2 +warnings, 3 +terse monitoring, ≥4 everything. |
ma57_batched_backsolve | no | — | POUNCE extension, not an Ipopt option — see below. |
Each can be scoped to the restoration sub-solve with a resto. prefix,
e.g. resto.ma57_pivtol=0.5, which leaves the main solve’s value alone.
Up to and including 0.10.0 none of the nine reached the backend. They were registered, documented, parsed and validated — and then discarded:
Ma57Options::from_options_listwas a correct reader with no callers, because every construction site went throughMa57SolverInterface::new(), which hard-codes the defaults. Set to any value, the factorization behaved as though you had set nothing (#825). They are wired from 0.11.0 on, which means a build that was tuning MA57 through these knobs will now actually be tuned by them — expect the trajectory to move.ma57_pivtolmaxalso now refuses a value belowma57_pivtolrather than accepting an empty escalation range.
MA57 batched back-substitution (ma57_batched_backsolve)
Only relevant in a --features ma57 build running linear_solver=ma57.
Under the limited-memory quasi-Newton Hessian the solver builds a Sherman-Morrison-Woodbury correction from a handful of extra right-hand sides. Those could go to the linear solver in one call instead of one call per column, which saves traversals of the factor — but only if the backend’s multi-RHS answer is bit-identical to solving the columns one at a time, because these columns feed an iterate whose trajectory must not move. So the solver asks first, and a backend that blocks its triangular substitution across columns has to say no.
MA57 blocks. Its batched answer is tolerance-equal but not bit-equal, so it says no by default, and this option is how you overrule it.
| Option | Default | Meaning |
|---|---|---|
ma57_batched_backsolve | no | yes lets the SMW correction hand MA57 all its columns in one blocked back-substitution. Accepts a ~1-ulp difference in the resulting iterate. Also takes a resto. prefix. |
Turning this on is a trajectory change, not a free speed-up.
Measured on a 118276-row KKT system, the same binary with and without
the batch diverges in the last printed digit of the objective at
iteration 20 and finishes at a different iteration count. On a
nonconvex problem a perturbation that size can select a different local
optimum — issue #729
is MA57 taking pooling_rt2stp to an objective 25% worse while still
reporting Optimal Solution Found.
What it buys, per iteration on that model:
| per iteration | column at a time | batched | Δ |
|---|---|---|---|
| back-solve | 0.1256 s | 0.0854 s | −32.0% |
| numeric factorization | 0.1443 s | 0.1711 s | +18.5% |
| linear algebra total | 0.2796 s | 0.2678 s | −4.2% |
against a 3–5% replicate spread, so the net is small and the headline row is not the whole story. Against Ipopt/MA57 on an equal-iteration basis it is more interesting: the back-solve row goes from 51% worse to 19% better.
Do not compare wall-clock across this option. The two settings walk different trajectories, so the difference is dominated by how many iterations each run happened to take rather than by work removed. In the measurement above the runs finished in 201 / 206 / 173 / 160 iterations, and the fastest arm was luck, not throughput.
Full write-up, including why the option has no width ceiling the way
the FERAL backend’s equivalent does: dev-notes/ma57-batched-backsolve.md.
Withdrawing the constraint perturbation (perturb_delta_c_max_rungs)
POUNCE extension; not an upstream Ipopt option. Default 3; 0
restores the pre-#592
escalation exactly.
When the KKT factorization does not deliver the requested inertia, the
solver climbs a ladder of perturbations: delta_w on the Hessian block,
and delta_c on the constraint block. delta_c is the remedy for a
rank-deficient constraint Jacobian, and it is reached for when the
factorization reports Singular.
Since #540 a
factorization also reports Singular when its inertia is
unmeasurable — the count disagrees and the smallest pivot sits at the
noise floor. That is evidence about the measurement, not about the
Jacobian’s rank. When the Jacobian in fact has full rank delta_c
cannot help, and because it stays switched on for the rest of that
augmented system, the delta_w ladder then has to climb against a
matrix delta_c has made harder to hit the requested inertia on. On
the #592 model that cost five rungs, ending at delta_w = 1e2 where
Ipopt accepted the step at 1e-4; the over-damped step froze the
objective for eight iterations and the solver exited at a point a
restart improved by 0.08%.
Rather than predict which kind of Singular a report was — the counts
are the very thing #540 established are noise — the ladder answers it
empirically. After this many rungs with delta_c on and still no
acceptable inertia, delta_c is withdrawn, the delta_w ladder
restarts, and delta_c is latched off for the remainder of that
augmented system; the next iterate starts clean. Lower values withdraw
sooner.
Where delta_c is the right remedy this never fires: on eigena2 and
eigenb2 it is followed by at most one rung. See
crates/pounce-common/src/pd_perturbation.rs
(maybe_withdraw_delta_c) and
dev-notes/issue-592-restart-non-idempotence.md.
Inertia-free curvature test (neg_curv_test_tol)
By default every KKT factorization is checked for the right inertia — as many negative eigenvalues as there are constraints — and the primal regularization δ_x is escalated until it has it. The inertia-free alternative of Zavala & Chiang (2014) factors without that check and instead asks whether the direction the system produced actually curves upward:
dxᵀ W dx + dxᵀ Σ_x dx + dsᵀ Σ_s ds [+ δ_x‖dx‖² + δ_s‖ds‖²]
≥ neg_curv_test_tol · (‖dx‖² + ‖ds‖²)
| Option | Default | Meaning |
|---|---|---|
neg_curv_test_tol | 0.0 | 0 keeps the inertia check. Positive is the test’s α_n: the factorization is accepted only if the direction clears the bound above, and otherwise δ_x is escalated exactly as a wrong inertia would. Upstream recommends 1e-12–1e-11. |
neg_curv_test_reg | yes | Whether the bracketed primal-regularization term counts toward the curvature. no is the original Ipopt form that ignores it. Only read when neg_curv_test_tol > 0. |
This is a heuristic, and turning it on is not free — it can change
the answer, not just the path to it. Measured over POUNCE’s fixture
corpus at the recommended 1e-11 (scripts/sweep-fixtures.sh, both
legs), 11 of 59 models move:
| model | default | neg_curv_test_tol=1e-11 |
|---|---|---|
csfi2 | Solved_To_Acceptable_Level, 35 it | Solve_Succeeded, 27 it |
unbounded_cubic | Diverging_Iterates, 290 it | Diverging_Iterates, 61 it |
cresc4 | 81 it | 90 it |
infeasible_equalities | Infeasible_Problem_Detected, 28 it | same, 37 it |
unbounded_exp | Error_In_Step_Computation, 27 it | same, 32 it |
eigena2 | 26 it | 421 it |
eigenb2 | 67 it | 960 it |
autocorr_bern55-06 | Solve_Succeeded, 72 it, obj -2304.000028 | 1042 it, obj -2288.000022 |
pooling_rt2stp | Solve_Succeeded, 298 it, obj -3273.954992 | Solved_To_Acceptable_Level, 537 it, obj -3085.16078 |
deb7 | Solve_Succeeded, 154 it | Error_In_Step_Computation, 183 it |
eigenb2 (L-BFGS leg) | Solve_Succeeded, 56 it | Error_In_Step_Computation, 76 it |
The last four rows are the reason to read this before switching it on.
deb7 and eigenb2-under-L-BFGS stop converging at all;
autocorr_bern55-06 and pooling_rt2stp still report success but land
on a worse objective — a tolerance-legal wrong answer, which is the
failure mode that is invisible to a suite asserting status and
objective-to-a-tolerance. Accepting a factorization whose inertia is
wrong is exactly the kind of change that produces it.
It is off by default (neg_curv_test_tol=0 keeps the inertia check),
and nothing above happens to a solve that leaves it alone. If you turn
it on, measure your own model.
Escaping a stationary point that is not a minimum (neg_curv_escapes)
The convergence test is a first-order test. On a nonconvex model that
is strictly weaker than “local minimum”: at a point where the reduced
Hessian on null(A) is negative definite every KKT residual is zero, so
the test has nothing to object to, and the point reported as
Solve_Succeeded can be a constrained maximum.
The CLI fixture nonconvex_qp.nl is that case in three lines:
min x₀·x₁ s.t. x₀ + x₁ = 2, 0 ≤ x ≤ 4
On the feasible segment the objective is f(x₀) = x₀(2 − x₀), which is
concave — maximized at (1, 1) with f = 1, minimized at the endpoints
(0, 2) and (2, 0) with f = 0. From the bound-pushed start
(0.01, 0.01) the first Newton step lands exactly on (1, 1), and every
iteration after it takes a step of size 1e-14.
Inertia correction does not prevent this and never could. It engages —
the iteration log shows lg(rg) from the second iteration on — but δ_x I
is symmetric, the model and the iterate are symmetric under x₀ ↔ x₁, and
a symmetric correction applied to a zero gradient gives a zero step however
indefinite the reduced Hessian is. Regularization makes the step
well-posed; nothing else in the algorithm asks whether the point it
converged to is a minimum.
| Option | Default | Meaning |
|---|---|---|
neg_curv_escapes | 1 | How many times a certified stationary point with an indefinite reduced Hessian may be left along a direction of negative curvature instead of reported. 0 reports the first-order certificate whatever its curvature. |
With this on, a point about to be certified is first tested for
second-order necessity: one extra factorization of the augmented system
with the inertia check on and no perturbation, whose correct inertia is
exactly the statement that W + Σ is positive definite on null(A). A
point that passes costs that one factorization and nothing else. A point
that fails gets δ_x escalated until the inertia is right, and a few
inverse-iteration back-solves against that factor recover the
most-negative-curvature direction — which is then measured, not trusted.
The solve steps along it (capped by the fraction-to-the-boundary rule,
backtracked against the second-order decrease model, refused outright if it
raises the constraint violation past constr_viol_tol) and continues.
It cannot return a worse answer than leaving it off would have. The
stationary point is snapshotted before the step and is restored and
reported unless the continuation comes back with a certificate of its own
at a better point — the same floor-and-deadline accounting as
resto_decline_deferrals, and the continuation is cut after 30 iterations
either way. Raising the option above 1 does not weaken that (gh #805):
the floor holds the best certificate the escapes have left, not the
most recent one, so every bet is placed against the same baseline the
first one was — the point a neg_curv_escapes = 0 build reports. Each
escape does buy its continuation its own 30 iterations, so the cost
scales with the option and the guarantee does not. On nonconvex_qp.nl — and on nonconvex_qp_ineq.nl, the same
model with its row relaxed to x₀ + x₁ ≥ 2 — it turns Solve_Succeeded at
obj = 1 into Solve_Succeeded at obj = 0; across the rest of the
fixture corpus (scripts/sweep-fixtures.sh, both legs, 152 fixture-legs) it
moves nothing.
Two limits are worth knowing:
- It is still a local method. An escape finds a point that is second-order suspect and leaves it; it does not certify global optimality, and a stationary point whose reduced Hessian is positive definite is never touched.
- Under
hessian_approximation=limited-memoryit does nothing. The curvature it reads isB, and BFGS maintainsBpositive definite by construction, so the inertia test passes atδ_x = 0and the escape declines. The L-BFGS leg of the fixture sweep still reportsobj = 1on both nonconvex-QP fixtures.
Bound relaxation and honor_original_bounds
Before the solve, the NLP arm widens every variable and constraint
bound by bound_relax_factor (default 1e-8, capped by
constr_viol_tol), exactly as upstream Ipopt does — a feasible-iterate
log-barrier needs x strictly inside its bounds, and this keeps the
iterates there without the user’s bounds becoming numerically
degenerate. The consequence is that a solution pinned to a bound is
reported just past it:
min (x − 3)² s.t. 0 ≤ x ≤ 1 → x = 1.00000000937
honor_original_bounds=yes projects the reported point back into the
bounds you declared, so that solve returns exactly x = 1. Reach for it
whenever the value flows somewhere that cares about the domain — a
sqrt(1 − x), a domain assertion, or a Pyomo Var the value is loaded
back into.
The default is no, matching upstream. As upstream also documents, the
constraint-violation and complementarity figures in the end-of-run
summary are for the non-projected point; only the reported x (and
the objective and constraint values evaluated at it) move.
Note what honor_original_bounds does not do: it moves the reported
point, not the solve. The iterate still stopped where the relaxed bounds
put it, and any question of the form “is this constraint active” is still
being asked about a point ~1e-8 shy of the bound. Projection cannot
recover that, because the projection has no way to tell “pinned to the
bound” from “genuinely 1e-8 inside it”. Crossover
answers that question instead: it re-solves against the bounds you
declared, so the returned point sits on them and the active set is
established rather than inferred.
The convex arm does not widen by default
The LP / convex-QP / conic arms solve the model exactly as declared.
Widening moves the optimum by δ times the bound’s multiplier, and
nothing bounds that product: on LISWET1 — every one of 10 000
monotonicity rows active, multipliers summing to 1.6e9 — a 1e-8
widening buys 9.0 of objective, a 33 % error against the published
optimum. Scored on the Maros–Mészáros optima (DOC 97/6) the convex arm
is 138/138 correct without it and 130/138 with it, so it is off by
default there.
Set bound_relax_factor explicitly and the convex arm applies it and
reproduces the NLP arm’s model exactly, so Ipopt parity remains
available on request.
The two arms therefore disagree on constraint-degenerate models, by
design. final_declared_constr_viol reports how far outside the model
as declared a returned point sits, on either arm, so the difference is
readable rather than silent; the console prints it as one extra line
whenever it differs materially from Constraint violation.
A convex solve that declines to certify is handed to the NLP arm (gh #535), and that re-solve also runs on the declared model unless you asked for a widening by name.
The same product is unbounded on the NLP arm (gh #967)
“Widening moves the optimum by δ times the bound’s multiplier, and
nothing bounds that product” is a statement about arithmetic, not about
the convex arm. It holds on the NLP arm too, where the widening is on
by default — and a large objective coefficient is all it takes:
min C·x + y/C s.t. x + y ≥ 1, x, y ∈ [0, 1]
The optimum is x=0, y=1 with value 1/C for every C. The NLP arm
returns x ≈ −1e-8 — the same constant box violation at every scale —
so the reported objective is off by C · 1e-8. At C = 1e12 that is
−1.0e+04: wrong by four orders, and negative, for an objective
that is non-negative everywhere on the declared box. The same model
through solver_selection=auto returns +7.8e-09, because that arm
does not widen.
This is not a defect with a default that fixes it. Both candidates were
measured and rejected — honor_original_bounds=yes trades box
feasibility for row feasibility (equality residuals from exactly zero
to ~1e-8 on 10 of 97 fixtures), and capping the widening by objective
sensitivity is vacuous wherever ∇f(x₀) = 0 and reaches a strictly
worse local solution on an MPCC. The reasoning and the numbers are in
dev-notes/bound-relax-objective-amplification.md.
What to do instead, in order of preference:
| situation | remedy |
|---|---|
| LP or convex QP | solver_selection=auto — that arm solves the declared model. The Python frontend defaults to nlp, so ask for auto by name. |
| you need a vertex | crossover=yes — establishes the active set against the declared bounds. |
| the box matters more than the rows | honor_original_bounds=yes, having read the trade above. |
| you want to detect it | read final_declared_box_viol, below. |
What Solve_Succeeded guarantees
Solve_Succeeded is a statement about the scaled KKT residuals of
the relaxed model. It is not a bound on the unscaled objective
error, and where nlp_scaling_method deflates the objective the two can
be many orders apart — on the model above, final_kkt_error = 1.6e-14
and final_unscaled_kkt_error = 2.7e-05 on the same exit.
A consumer that needs an unscaled guarantee should read the numbers rather than branch on the status:
final_unscaled_kkt_error— the max-norm KKT error in your units.kkt_fidelity_tolturns a threshold on it into a status downgrade (off by default; it is a pure relabel and does not change the answer).final_declared_box_viol— how far outside the box as declared the returned point sits.final_declared_constr_violis its row counterpart. Both are reported on every arm, and the console printsViolation of the model as declaredwhenever it differs materially.
Note that Solved_To_Acceptable_Level is still success=True on the
SciPy-shaped Python result: it is a converged answer at the acceptable
tolerance, not a failure, so a downgrade alone will not trip a caller
that only checks success.
Large constraint values and primal_noise_floor_kappa
On a model whose constraint values run to ~1e7 and beyond, a converged
solve could exit Search_Direction_Becomes_Too_Small while holding the
correct optimum. The cause is arithmetic, not the model.
The KKT error the convergence test compares against tol is
max( ‖∇L‖∞ / s_d , max(‖c‖∞, ‖d − s‖∞) , ‖compl‖∞ / s_c )
The dual and complementarity terms are normalised; the primal one — like
upstream Ipopt’s — is a bare absolute residual. But c_i = g_i(x) − b_i
and d_i − s_i are each a difference of quantities the row’s own size,
so they are quantised in units of eps · |b_i|. At |b| ~ 1e8 the
smallest nonzero value the primal term can take is one ulp,
1.5e-8 — already larger than the default tol = 1e-8. Asking for
nlp_err <= tol there is asking the residual to land on a bitwise-exact
0 rather than on one ulp, which is arithmetic luck rather than a
property of the iterate.
POUNCE therefore judges the primal term in the strict test against
each row’s own floating-point resolution: a row’s residual counts only
where it exceeds max(placement floor, kappa · eps · |row magnitude|),
with kappa = primal_noise_floor_kappa (default 64). Verdicts are flat
across kappa from 8 to 1024 on the measured set.
Three things bound what this can do:
- Only the strict test reads it.
constr_violis still checked againstconstr_viol_tol(default1e-4) on the full, unfloored residual, so nothing the floor forgives can exceed the feasibility tolerance you set — however large your data grows. - The acceptable-level band keeps the raw error. It sits two decades
above
tol, clear of any realistic quantum. - It cannot rescue an infeasible model. On a model with no feasible point the filter and restoration phase reach a verdict on their own criteria; the floor only ever participates at a point the rest of the algorithm already believes is converged.
Set primal_noise_floor_kappa = 0 to switch the floor off and restore
upstream Ipopt’s bare-absolute primal term exactly.
When the floor changes the reported picture, the end-of-run summary says
so — a large-|b| solve prints the tested value under the raw one:
Overall NLP error.......: 2.3841857910156250e-07 2.3841857910156250e-07
...above the per-row floating-point noise floor: 0.0000000000000000e+00
Solves where the two agree — every model whose data is O(1) — print the
usual block unchanged.
One case this does not paper over: tightening constr_viol_tol below
a row’s own ulp (say 1e-8 on data at 1e8) still will not certify. That
is the tolerance gate doing what you asked — the residual you requested is
not representable at that scale.
Solved_To_Acceptable_Level and acceptable_progress_kappa
Solved_To_Acceptable_Level is the fallback verdict for a solve that
cannot reach tol: after acceptable_iter (default 15) consecutive
iterates with an NLP error under acceptable_tol (default 1e-6), the
solver stops and hands back the point it has. That criterion is a count of
iterates inside a band, and on its own it asks only is the error small —
never has anything stopped moving.
Those come apart. An interior-point iterate can be near-stationary for the
current barrier subproblem — a much weaker statement than near-KKT for
the NLP — for fifteen iterations running while the solve is still
descending. Two measured cases: the kissing model stopped with objective
1.00000108 where continuing reaches 0.84544259 and a strict
certificate, 18% lower; NARX_CFy stopped with both residuals near 1e-7
where sixty more iterations collapse them by five orders.
POUNCE therefore also requires the streak to have flattened. Across the
acceptable_iter iterates that made it up:
- the spread (
max − min) of the NLP error must be withinacceptable_progress_kappa · acceptable_tol; and - the spread of the objective within the same fraction of
acceptable_tol · max(1, |f|).
acceptable_progress_kappa defaults to 0.1, so at default tolerances
both quantities must have stayed inside a tenth of the acceptable band over
the whole streak.
It is a spread, not a trend, and either signal alone is enough to keep
solving. Both choices are deliberate: kissing’s error was an order of
magnitude worse at the iterate it stopped on than at one it had already
reached inside the same streak — it was wandering across the band, not
converging inside it — while its objective was flat to all eight printed
figures over the same iterates.
Three things bound what this can do:
- It cannot lose a verdict. The refused termination is recorded, and
a run that fails to do better ends at exactly that iterate under exactly
that status. A misfire costs iterations, never the answer — you will not
see
Maximum_Iterations_Exceededwhere the count alone would have saidSolved_To_Acceptable_Level. - It never looks at a solve that converges. A solve that reaches
tolnever completes an acceptable-level streak, so nothing here runs. - A genuine stall flattens. When the iterate, the objective and the error are all pinned — the case the acceptable-level exit exists for — the window is flat and termination happens as before.
Set acceptable_progress_kappa = 0 to switch the progress test off and
restore upstream Ipopt’s bare consecutive-count criterion. Widening
acceptable_tol widens the flat bar with it, so asking for a looser band
still gets you the early exit.
A settled point with a runaway multiplier (dual_divergence_retry)
Some models have a solution at which a constraint’s gradient vanishes. The row is satisfied, the primal iterate is exact — but the multiplier that would certify it is arbitrary rather than nonexistent, and the barrier drives it off to infinity.
The standard case is a complementarity constraint lowered as a product,
G(x)·H(x) = 0, at a point where the pair is biactive: G = 0 and
H = 0 together. The product’s gradient is H∇G + G∇H, and both terms
vanish there. MPCC lowerings (ncp_eq, prod_eq) reach such points
routinely; MacMPEC’s qpec_small does at its solution (1, 1, 0).
This used to ship a wrong verdict, because the convergence gate reads an
NLP error normalised by s_d, and s_d grows with the mean multiplier
magnitude — so a runaway multiplier divides itself out of the number the
gate tests. One line of the summary block, on qpec_small at
bound_relax_factor=0:
(scaled) (unscaled)
Overall NLP error.......: 8.2335532426389998e-11 7.8965510781517834e+04
Fifteen orders apart. The gate read the left column and reported
Solved_To_Acceptable_Level.
POUNCE now watches for the signature directly. At one and the same iterate:
- the primal infeasibility is at zero (
≤ 1e-8); - the step has settled —
maxᵢ |dᵢ| / (1 + |xᵢ|), over thexandsblocks, is at or belowdual_divergence_retry_step_tol(default1e-5); and - the unscaled Lagrangian-gradient norm is at or above
dual_divergence_retry_du_floor(default1e2).
The middle conjunct is the one doing the work, and it is what separates
this from an iterate that is simply diverging: a diverging solve has a
large step, not a zero one. It is also the only barrier protecting models
with no sign-feasible multiplier, where the remedy below reaches a
plausible-looking answer below the true optimum. Measured: qpec_small
settles to 4.3e-8, MacMPEC ralph1 — which has no multiplier at all —
bottoms out at 7.2e-3. The 1e-5 default sits between them, and a fixture
pinning that gap ships in
crates/pounce-algorithm/tests/issue_884_biactive_dual_divergence.rs.
Only a model with at least one constraint row is eligible: on an
unconstrained model ∇L ≡ ∇f, so the third conjunct would be a second,
much looser copy of dual_inf_tol.
When the signature is seen and the solve ends Solved_To_Acceptable_Level
or Restoration_Failed, POUNCE re-solves once from cold with
perturb_always_cd=yes — regularising the constraint block from the first
factorisation rather than waiting for an inertia failure that a vanishing
gradient never produces. The second answer is returned only if all of:
- the retry ends
Solve_Succeeded; - its unscaled KKT error and constraint violation are both within
acceptable_tol; - its unscaled KKT error is strictly better than the first attempt’s; and
- its answer is admissible next to the first attempt’s — it may not
return a strictly worse objective, and an objective improvement may
not be bought with primal slack (a better objective at a larger
constraint violation is refused). Both comparisons use
acceptable_tolscaled bymax(1, |first objective|), and both stand down when the first attempt is not itself feasible within that tolerance, since there is then no admissible answer to protect.
Those two statuses are the whole scope, and they are the two a vanishing
gradient row produces directly: Solved_To_Acceptable_Level is gh#884
verbatim, and Restoration_Failed is the same defect one step earlier.
Error_In_Step_Computation and Maximum_Iterations_Exceeded are
deliberately not on the list even though the detector can legitimately
fire before them — they are generic exhaustion exits that any hard model
can reach for unrelated reasons, and retrying there buys nothing while
costing a full second budget. See the cost note below for the measurement
that set this.
Otherwise the first attempt’s status, point, statistics and final trace row are all put back — the retry costs iterations, never the answer. Condition 2 is what stops the gate reproducing the bug one attempt later: the defect was a status its own unscaled residual contradicted, so a promotion rule reading the status alone would launder it again.
Condition 4 is a separate barrier, not a restatement of the others.
Conditions 1–3 rank the two attempts on their certificates, and a
certificate cannot say which of two feasible points you should receive:
any other KKT point satisfies the KKT conditions in the model’s own units
just as well. Without condition 4, measured over 400 random QPECs under
the exact-product lowering, 42 of 68 promotions returned a different local
solution and three returned a strictly worse feasible point — worst case
-13.0057 given up for -1.2072. On MacMPEC’s scholtes4, whose optimum
is exactly 0, the retry returned -6.61e-05 — unreachable by any
feasible point — by moving the complementarity row from 2.07e-25 to
1.09e-09, and reported Optimal Solution Found.
When condition 4 is what refuses a retry, the console says so explicitly — “declined on the ANSWER, not the certificate” — with both attempts’ objectives and constraint violations, because a converged retry with a clean certificate being refused otherwise reads as a contradiction.
On qpec_small the retry takes the unscaled KKT error from
7.8966e+04 to 9.9636e-08 — nine orders — at the cost of a primal
residual that goes from 1.1e-16 to 5.5e-12, and a point 3.7e-06
further from (1, 1, 0). That trade is the point: a marginally looser
answer that comes with a certificate a reader can check.
Cost. The detector runs once per iteration and reads quantities the
convergence check already computes. The retry itself is the outermost
wrapper, so it never runs where an inner one already won — at default
options qpec_small is rescued by the μ-strategy fallback (see
“Barrier-parameter (μ) strategy” below) and the dual-divergence retry
spends nothing. Worst case is one extra solve, under your own max_iter,
on a run that was already reporting a non-success verdict.
Among acceptable-level exits in the 80-fixture regression corpus, on both
sweep legs, nothing else reaches the floor: the closest non-MPCC approach
is eigena2 under L-BFGS at an unscaled dual of 37, and its step is
7.9e-9 — settled, but two orders under the 1e2 floor. One fixture does
reach the floor at another status, and it is why the scope names statuses:
deb7 under L-BFGS settles at iteration 346 to a step of 6.5e-6 with an
unscaled dual of 9.2e+05 — above qpec_small’s on the dual conjunct,
so no floor excludes it, and separable on the step conjunct only by
tightening the default onto one fixture and spending the margin that holds
ralph1 out. There the detector is right and the remedy is not: an
earlier build that retried on Error_In_Step_Computation spent 715 → 3000
iterations to return the same status and the same objective.
Scoping by status is only as complete as the status is stable, and on that
same fixture it is not: under limited_memory_ls_failure_restarts=1 (off
by default) deb7 exits Restoration_Failed instead of
Error_In_Step_Computation, so it is in scope.
So there is a second gate, and it reads the answer rather than the
trajectory. The detector fires on an iterate; nothing in it says the
solve ends there. A run can pass through a settled point with a diverged
multiplier, work its way back down, and report something ordinary — and
then there is nothing left for perturb_always_cd to repair.
What #884’s defect looks like in the answer is a point converged except
that one multiplier ran away: the primal is exact, complementarity is met,
and the whole residual is dual infeasibility. So the retry runs only when
the reported answer’s unscaled constraint violation and unscaled
complementarity are both at or below 1e-6 times its unscaled dual
infeasibility:
| run | unscaled dual | viol | compl | ratio |
|---|---|---|---|---|
| the #884 reproducer | 7.90e+04 | 1.1e-16 | 1.1e-09 | 1.5e-14 |
deb7 + L-BFGS + rung, macOS | 9.90e+01 | 8.0e-13 | 4.65e+00 | 4.7e-02 |
Twelve orders. deb7’s complementarity is five percent of its own KKT
error — that answer is not a converged point with a runaway multiplier, it
is an unconverged point, and before this gate that run paid a full cold
re-solve (6.1 s to 25.2 s) to decline an answer that was never going to be
promoted (#887).
The test is a ratio within one answer on purpose. A floor on the
reported residual would be a threshold on a scale-dependent quantity, and
it does not even separate these cases — deb7’s 9.9e+01 sits one percent
under the detector’s own 1e2. Comparing the answer against the runaway
the detector saw does separate them, but it reads two numbers from a
trajectory, and a trajectory is not stable across platforms. A ratio
between two residuals of the same answer carries no units and cannot move
that way.
One consequence is worth knowing if you are reading a report from a hard
model: whether this gate opens is a property of the answer, not of the
machine, but which answer a hard model reaches can differ between
platforms. deb7 under that rung is the measured example — objective
99.677 on macOS against 99.651 on Linux, and on Linux the answer it
reaches genuinely does carry the runaway, so the retry runs there and is
supposed to.
Set dual_divergence_retry=no if you are running a hard model to a
failure verdict and even one extra attempt is not worth the clock.
Two off switches, coarse and fine:
dual_divergence_retry=nodisables the retry outright. The detector still runs and the report still records what it saw.dual_divergence_retry_step_tol=0holds the detector off, so nothing downstream of it can fire.
Both the signature and the retry’s outcome are reported. The summary block prints
Biactive dual divergence (gh#884) = detected
when the signature was seen and the solve did not end Solve_Succeeded
(passing through such an iterate and recovering is routine on an MPCC, and
a warning over a correct answer is noise), and the retry prints its own
verdict line when it runs. In the JSON report the two are
statistics.dual_divergence_signature and
statistics.dual_divergence_retry_promoted.
Big models that start feasible and the theta_max ceiling
The filter has a hard ceiling. Any trial iterate whose constraint
violation θ exceeds
theta_max = theta_max_fact · max(1, θ₀)
is rejected outright, before any of the filter’s usual tests run. It is a global-convergence safeguard: it keeps the line search from wandering arbitrarily far from feasibility.
The trouble is the 1. POUNCE’s θ is a 1-norm over constraint rows —
‖c‖₁ + ‖d − s‖₁, a sum of m residuals — so a ceiling of T really
says “a mean per-row violation of T/m”, and that allowance shrinks as the
model grows. And on a problem started at a feasible point, θ₀ = 0,
the max collapses and the ceiling is the bare constant theta_max_fact
however large the model is.
robot_a is the measured case: 52 013 constraint rows, a feasible start,
so theta_max locked at 1e4 — a mean per-row allowance of 0.19 — while
the route to the optimum passes through θ ≈ 9.4e7. Every step toward the
solution was refused at the gate, and the solve ground to its iteration
limit at objective 8.173304 instead of the true 1.0431952.
POUNCE’s answer is theta_max_adaptive_trigger (default 3), described
below. theta_max_row_scale_kappa (default 0, off) is an earlier,
static attempt at the same problem, kept because it is occasionally the
more direct lever; it floors the reference at the row count instead:
theta_max = theta_max_fact · max(θ₀, theta_max_row_scale_kappa · rows, 1)
so the ceiling means a mean per-row violation of theta_max_fact
regardless of m. Measured under defaults otherwise, against Ipopt 3.14
on the same machine:
| model | POUNCE default | POUNCE kappa = 1 | Ipopt (default) |
|---|---|---|---|
robot_a | Maximum_CpuTime_Exceeded, 8.173304 | Optimal, 1.0431952, 112 it | Maximum_Iterations_Exceeded, 8.173304 |
robot_b | Maximum_CpuTime_Exceeded, 15.484684 | Optimal, 2.3330990, 252 it | Maximum_Iterations_Exceeded, 15.484684 |
robot_c | Maximum_CpuTime_Exceeded, 29.039906 | Optimal, 1.4059756, 109 it | Maximum_Iterations_Exceeded, 29.039906 |
Ipopt has the same defect and no correction for it; all three solve under
theta_max_fact = 1e8 set by hand, which is the blunt version of the same
move.
The adaptive rule: theta_max_adaptive_trigger
On by default. Rather than guessing from problem size whether a model needs headroom, POUNCE measures whether the ceiling is what is refusing the line search, and raises it only then.
A trial refused because θ_trial > theta_max takes a distinct early exit,
before the filter and Armijo tests run at all. So the acceptor can count
those refusals and compare them against the number of trials attempted.
When every trial of a line search was refused at the gate, for
theta_max_adaptive_trigger consecutive line searches, the ceiling is
demonstrably the binding constraint — not the filter — and it is
multiplied by theta_max_adaptive_factor (default 100), at most
theta_max_adaptive_max_raises times per solve (default 4).
| option | default | meaning |
|---|---|---|
theta_max_adaptive_trigger | 3 | consecutive fully gate-refused line searches before a raise; 0 disables |
theta_max_adaptive_factor | 100 | geometric factor per raise |
theta_max_adaptive_max_raises | 4 | cap on raises per solve |
Three properties follow, and they are what the static floor could not offer:
- A converging model cannot trip it. Converging means trials are
getting past the gate; a model accepting steps never accumulates the
streak.
brainpc1/3/5/7— the family the static floor damaged at everykappa— are untouched by construction, not by a lucky constant. - A blocked model trips it immediately.
robot_ais refused at the gate from its first line search onward. - The ceiling stays finite. Wächter–Biegler’s global-convergence
argument (Thm. 2) needs
theta_maxfinite, not fixed. A bounded number of bounded raises keeps it finite, so a solve cannot ratchet the safeguard away one line search at a time.
Requiring a streak rather than a single line search is deliberate: one
Newton direction that overshoots into a huge θ can legitimately have
all its trials refused, and backtracking is the right response to that.
Only a model that cannot get past the gate repeatedly is one whose route
needs the headroom.
Measured, defaults otherwise:
| model | rule off (trigger = 0) | rule on (default) |
|---|---|---|
robot_a | Maximum_Iterations_Exceeded, 14.23 | Optimal, 1.0432009, 190 it |
robot_b | max time, 15.484684 | Optimal, 2.3330990, 269 it |
robot_c | max time, 29.039906 | Optimal, 1.4059756, 222 it |
brainpc1 | Optimal, 64 it | Optimal, 64 it — identical |
brainpc3 | Optimal, 43 it | Optimal, 43 it — identical |
brainpc5 | Optimal, 982 it | Optimal, 982 it — identical |
brainpc7 | Optimal, 43 it | Optimal, 43 it — identical |
bt4 | Optimal, 9 it, −3.7047681836394486 | identical |
Across the whole Vanderbei corpus (733 problems) the rule changes four
outcomes: britgas goes from its iteration limit to Optimal in 16
iterations, catenary solves to the same objective in 50 iterations
instead of 56, and coshfun and brainpc0 fail either way — coshfun
now reporting diverging iterates, which is what Ipopt 3.14 also does on
it. Net Optimal count 702 → 703.
Note brainpc0 does trip the rule while brainpc1/3/5/7 do not,
despite identical row counts. That is precisely the distinction a
size-based floor cannot draw.
The restoration sub-IPM always runs with the rule disabled. Upstream
already corrects the resto phase’s instance of this degeneracy by
hard-coding resto.theta_max_fact = 1e8 (IpRestoMinC_1Nrm.cpp:91), so a
rule that ratchets further would be compounding a correction already made.
Set theta_max_adaptive_trigger = 0 to restore upstream Ipopt’s fixed
ceiling exactly.
When to reach for the static floor instead
Symptoms, all three together:
- a large number of constraint rows (thousands upward);
- a feasible or near-feasible starting point — the iteration log’s
first
inf_pris0or very small; - the solve stalls with
inf_prflat andalpha_prtiny, and raisingmax_iterdoes not help.
The quick confirmation is to set theta_max_fact = 1e8 by hand. If that
unsticks the model, theta_max_row_scale_kappa = 1 is the principled
version of it — it scales the ceiling to the model rather than to a
constant you picked.
Why the static floor is off by default
Because raising the ceiling unconditionally is not free. It relaxes a global-convergence
safeguard, and a model that was not being blocked by it can wander
instead. On the Vanderbei corpus, brainpc1/3/5/7 (m = 6900,
θ₀ = 1e-2) all regress — brainpc1 from Optimal in 64 iterations to
divergent, objective 3.7e3 against the correct 4.4e-04.
A scan over kappa showed why this cannot be tuned away. The damage is a
step function, not a gradient:
| kappa | robot_a | brainpc1 | brainpc3 | brainpc7 |
|---|---|---|---|---|
| 0 (default) | max time, 616 it | Optimal, 64 it | Optimal, 43 it | Optimal, 43 it |
| 0.01 | Optimal, 287 it | max time, 3.8e8 | Acceptable, 149 it | Acceptable, 552 it |
| 0.05 | Optimal, 153 it | max time | Acceptable, 149 it | Acceptable, 552 it |
| 0.2 | Optimal, 127 it | max time | Acceptable, 149 it | Acceptable, 552 it |
| 1.0 | Optimal, 112 it | max time | Acceptable, 149 it | Acceptable, 552 it |
brainpc3 and brainpc7 land on the identical worse answer at every
nonzero kappa, even 0.01 — where the ceiling moves only from 1e4 to
6.9e5. The instant it rises at all, they break. robot_a meanwhile
improves monotonically all the way to kappa = 1. There is no separating
value.
That is a verdict on the design, not on the tuning: the real question is whether a model’s route to the optimum needs the extra headroom, and the row count does not answer it. A static floor cannot know — which is why the adaptive rule above, which asks the question directly, is the default and this one is not.
What still bounds the option when you do turn it on:
- It only ever raises the reference. A model whose own
θ₀already exceedskappa · rowsgets exactly upstream’s ceiling. - It reduces to upstream on a single-row problem, where the floor is
max(kappa · 1, 1) = 1— upstream’s constant. theta_maxis still finite, and still fixed for the whole solve after its first line search. This rescales the safeguard; it does not remove it.- The restoration sub-IPM is untouched, at any
kappa. Upstream already fixes its own instance of this by hard-codingresto.theta_max_fact = 1e8(IpRestoMinC_1Nrm.cpp:91) — the resto NLP is also initialised feasible, so it hit the same degeneracy. Stacking the row floor on top would push that inner ceiling to1e8 · m, i.e. remove it, so the sub-IPM always runs withkappa = 0.
Large gradients and dual_inf_scale_kappa
The dual side of the same story. dual_inf_tol (default 1.0) is a bare
absolute bound on ‖∇L‖∞ — but the aggregate above normalises
that quantity, dividing it by s_d, which grows with the mean magnitude
of the multipliers. On a model whose gradients live at 1e10 the two
gates are judging one number by standards ten orders apart.
Vanderbei’s orthrds2 is the reported case: s_d ≈ 1.6e10 with
‖∇L‖∞ = 89.7, so the aggregate’s dual term is 5.6e-09 — comfortably
inside the default tol = 1e-8, i.e. stationary to nine digits relative
to the size of the gradients involved — while the component gate refused
it against 1.0. The solve exited Solved_To_Acceptable_Level holding
the answer, and dual_inf_tol=1e3 alone turned it into
Optimal Solution Found at the same objective.
The simplest statement of the defect: multiply an objective by a positive
constant. Same feasible set, same solution, same active set, same Newton
step — and every multiplier, s_d and ‖∇L‖∞ scale with it, so a large
enough constant costs the certificate.
The strict test therefore judges the unscaled dual infeasibility against
max( dual_inf_tol , kappa · tol · dual_scale )
with kappa = dual_inf_scale_kappa (default 1) and dual_scale the
magnitude of the largest single term ∇L is assembled from (∇f,
Jᵀy, the bound multipliers). Since ∇L is the sum of those terms,
‖∇L‖∞ / dual_scale is the fraction of them that failed to cancel — a
scale-invariant statement of stationarity, and the thing the absolute
bound was standing in for.
What bounds it:
- It cannot forgive non-stationarity. A point where nothing cancelled
has
‖∇L‖∞ ≈ dual_scale, a ratio of1against a bar of1e-8.min −exp(x) s.t. x >= 0running away toinf_du = 8.8e+47is refused by eight orders, because its∇fruns away by exactly the same factor. - The aggregate still has to pass.
nlp_err <= tolis tested on the same iterate; this only removes the second, inconsistent standard. - It is inert on ordinary models. At the defaults the floor does not
rise above
dual_inf_toluntildual_scaleexceedsdual_inf_tol / tol = 1e8, so every model withO(1)gradients keeps upstream’s comparison bit for bit. - Only the strict gate reads it.
acceptable_dual_inf_tol(1e10) is untouched.
Set dual_inf_scale_kappa = 0 to switch the floor off and restore
upstream Ipopt’s bare-absolute bound. That is also the setting to reach
for if you tighten dual_inf_tol and want that absolute standard
honoured unconditionally — the floor is a floor, so it can override a
tightened dual_inf_tol on a large-gradient model. Since gh#983 this is
automatic: setting dual_inf_tol explicitly to a value other than its
default 1, without also naming dual_inf_scale_kappa, switches the floor
off. Echoing the default (dual_inf_tol 1, as a front end passing the whole
option table does) is read as not naming it and keeps the floor; use
dual_inf_scale_kappa 0 for the absolute standard at the default tolerance.
Name both to keep the floor under a tolerance of your own.
s_max — where s_d and s_c come from
The two normalising factors above are built from the multipliers
themselves, capped by s_max (default 100, upstream’s):
s_d = max( s_max , (‖y_c‖₁+‖y_d‖₁+‖z‖₁+‖v‖₁) / (their total dimension) ) / s_max
s_c = max( s_max , (‖z‖₁+‖v‖₁) / (their dimension) ) / s_max
Both are exactly 1 while the multipliers average below the cap — which
is every well-scaled problem, and why the option is invisible there — and
grow as mean / s_max once the average passes it. Raising s_max
therefore delays the normalisation (the KKT error stays closer to the raw
residuals); lowering it applies the normalisation sooner and makes the
scaled error smaller for the same iterate, so the solve certifies
earlier. The scaled and unscaled numbers are both reported: --json-output
carries final_kkt_error and final_unscaled_kkt_error, and their ratio
is exactly what s_max controls.
Success verdicts and the model’s own scale (solve_quality_audit)
The strict convergence test runs on a scaled aggregate. Gradient-based scaling measures the objective gradient once, at the starting point, freezes the factor, and only ever scales down. Two failures follow, one on each side, and neither is visible in the scaled space (gh#983):
- Frozen at a huge-gradient start. LJ7 from a start whose gradient is
3e8freezes a factor of3e-7; the scaled test passes at a point whose unscaled stationarity residual is2.9e-2, and continuing under that factor does not improve it.pounce.find_minimawithouthess=then accepted gradients of~1as minima. - Small user objective. A profit in M$/L has a gradient of
1e-5and nothing scales it up, so the absolute complementarity floor (~mu) is2.5e-4of the objective’s own scale:Solve_SucceededatT = 359.9513against an active bound of360,4.7e-4(relative) off the optimum.
With solve_quality_audit=yes (the default), a Solve_Succeeded /
Solved_To_Acceptable_Level verdict that shows either signature is re-solved
once from the returned point: the first with the scaling re-evaluated
there, the second with the objective multiplied by 1/g (capped at 1e10),
where g is the larger of max|grad f| at the start and at the returned
point. A re-solve is spent only when it would change something:
- the re-scale only when the factor gradient-based scaling computes at the
returned point is at least
10xthe frozen one (a legitimately large objective,hs71times1e8, re-evaluates to the factor it already has and is not re-solved); - the up-scale only when the unscaled complementarity exceeds
1e-7ofg(on the reactor model the relative objective error is about twice that ratio) and1/gis at least10. A start that happens to sit near a stationary point does not read as a tiny objective, becausegalso reads the gradient at the answer. When the ratio is exceeded but1/g < 10, or the caller owns the scaling (nlp_scaling_methodnone/user-scaling), the run gets theobjective_scale_smallwarning instead of a re-solve, so there is no band of objective scales that gets neither.
The re-solve is promoted only if it returns a clean verdict, is no
worse in the model’s own units, strictly improves the quantity that triggered
the audit, and is admissible next to the first answer (the same rule as the
gh#884 retry); otherwise the first attempt’s status, point and statistics are
restored. equilibration-based scaling, which pounce currently runs as
gradient-based, is audited as gradient-based. Cost: one extra solve, from a
converged point, on a run that shows a signature.
A non-finite gradient at the start carries no scale. x ln x sampled at
x0 = 0 has an infinite derivative, which 100/inf would have floored at
nlp_scaling_min_value (1e-8). On a model whose gradient is O(1)
everywhere the iterates go, that cost a 16-iteration solve plus an 8-iteration
audit re-solve. Pounce now treats it as a failed gradient evaluation and
leaves the objective unscaled (df = 1), as upstream does when its gradient
evaluation fails, and as Ipopt under ASL does on the same .nl, since ASL
rejects log(0). Gibbs minimization from the origin then takes one 10-iteration
solve, the same as Ipopt (gh#1001).
The audit does not fire when the large gradient is real.
min 1e8·x + x²/2, x ≥ 0 has a gradient of 1e8 at the start and at the
answer, so a re-scale would freeze the same factor and re-trace the same path.
It ends at f ≈ -1 against f* = 0. That error is not a scaling error: the
default bound_relax_factor = 1e-8 moves the bound to x ≥ -1e-8, and
1e8 · 1e-8 = 1, so pounce solved the relaxed problem correctly. The
complementarity floor’s share is 9e-6, 9e-14 of the gradient. A model
whose objective is that steep in a bounded variable needs
bound_relax_factor=0 (Ipopt 3.14 returns the same -0.99999).
Whatever happens, the run carries structured warnings
(info["warnings"], the JSON report’s statistics.warnings, WARNING:
console lines): objective_scale_small, unscaled_stationarity_above_tol,
large_dual_scale, unscaled_dual_inf_above_acceptable. They describe the run
whose point is returned (a discarded retry’s warnings go with it), each code
appears at most once, and they are printed once per solve. A warning does not
change the status, with one exception that the audit makes on the final
verdict only, after every retry has decided: a Solve_Succeeded from a run
that itself showed the gh#884 signature (a settled primal with a runaway
multiplier) and still carries an unscaled dual infeasibility above
max(acceptable_tol, 1e-3) is reported as Solved_To_Acceptable_Level, with
unscaled_dual_inf_above_acceptable saying so. A residual within 64 ulps of the
terms it is assembled from (64·ε·dual_scale) is roundoff, not a miss, and
keeps the success verdict (gh#1000): with multipliers near 1e13 a residual of
5e-3 is all floating point can resolve. large_dual_scale still flags such a
run.
Why the scale-relative floor is not capped. An explicitly set
dual_inf_tol is honoured (see dual_inf_scale_kappa above). For a caller
who set nothing, the floor kappa * tol * dual_scale rises above
dual_inf_tol only once the terms of the stationarity residual reach 1e8.
The cusp of gh#983 (multipliers 3.5e9, residual 0.144) would be rejected
by a cap near 0.1, but so would Vanderbei’s orthrds2 (residual 89.7 at
scale 1.6e10, stationary to nine digits relative to its scale). A cap
cannot separate them by magnitude alone. Instead such a point carries the
large_dual_scale warning, which is the symptom of the failed constraint
qualification that produced the multipliers.
A common way to cause that failure is bound_relax_factor=0. It gives an
active bound that coincides with a dependent row no interior, and the
multipliers drift along the ray of valid ones. When the option is 0, the
warning says so. On the optimization book’s models from gh#1000 (Haverly
multistart, the process-network zero-flow subproblems, the pump network),
the default 1e-8 ends Solve_Succeeded every time, and Ipopt at 0
follows the same trajectory pounce does.
Objective sense and obj_scaling_factor
obj_scaling_factor multiplies the objective the IPM minimizes, so a
negative value maximizes — upstream’s documented spelling for a
maximization problem stated as a minimization. Because it changes what is
being optimized rather than just its conditioning, it is honored only by
the general NLP interior-point path: a model that would otherwise route
to the specialized convex solvers (LP / convex QP / SOCP, see
LP/QP Routing) is re-routed under
solver_selection=auto, and an explicit convex solver_selection is
refused rather than silently answering with the minimizer.
A positive factor is a pure conditioning knob; the convex path reports natural units either way, so it keeps the fast path.
Warm-starting an NLP after a parameter change
warm_start_init_point=yes (or passing a WarmStart from Python) starts the
solve from a supplied primal/dual iterate. Starting from a nearby solution
does not by itself make the solve short, and one parameter change used to make
it much longer: when a constraint that is active at the carried point has
its bound move away (a capacity K raised from 14 to 16), the carried point
still has multiplier 1.41 on a row whose slack is now 2 wide. The slack
multiplier the initializer rebuilt was barrier-sized (mu / slack), so the
start carried 1.41 of dual infeasibility that no step could repair at a
tiny mu, and the solve spent its first dozens of iterations in restoration
(gh#988: 49 iterations against 7 cold).
Since the gh#988 second pass the initializer treats the supplied equality /
inequality multiplier as the authority on activity. When the stationarity of
the slack row (-y_d + v = 0) asks for a slack multiplier more than ten times
what mu / slack supports, the multiplier it implies is taken and the slack
is closed onto its bound at mu / v (the move costs a primal infeasibility of
the old slack, which the first Newton step removes along the active
constraint). That model now warm-starts in 4 iterations (cold: 7), and no
warm_start_* option is needed. Seeds whose stationarity miss is not
coherent with the primal point (a corrupted y) are still refused by the
gh#617 test and keep the barrier-sized fill, and the gate never touches
variable-bound multipliers.
The move is capped (gh#988 review): a slack is closed only when the primal
move that repairs it, slack / ‖∇d_j‖∞, is at most 0.5·max(1, ‖x‖∞). Past
that the bound has moved too far for “still active” to be the likely reading —
capacity 14 → 100 would have closed an 86-wide slack and started the solve 86
infeasible — and the capped split is used instead. The cap is read in primal
units so that it does not depend on how the row is written (gh#1000): it was
once 0.5·max(1, |s|), which refused the repair to body - K <= 0 (where
|s| ≈ 0) and granted it to the identical body <= K. info["warm_start"]["primal_residual"] is measured on the point
the solve actually starts from (after the move), and slacks_closed /
slack_close_reverted report what the move did. A row the carried point
violates (a capacity tightened 14 → 12) is active by construction, so its
slack multiplier takes the value the seeded y_d implies rather than the
constant fill (24 → 4 iterations, cold 6). Across the reviewer’s sweep
K ∈ {8, …, 1e4} the warm start is now no slower than cold at every K.
warm_start_recentering set on the Problem with add_option before a
solve(warm_start=...) takes precedence over the WarmStart’s own
recentering field (it used to be overwritten, so none silently ran
residual). With none the K = 16 case takes 97 iterations — the
reconstruction above is what makes it 4.
| Option | Default | Meaning |
|---|---|---|
warm_start_init_point | no | Use the supplied primal and dual iterate as the start. |
warm_start_target_mu | 0 | When > 0, start the barrier at exactly this mu, overriding mu_init and the recentering pass. An explicit instruction: still wins over the residual estimate. Useful for a small, well-understood parameter change (1e-4 takes the gh#988 model from 4 to 3-5 iterations); leave at 0 for a stale or distant point, where a larger start mu is the safer choice. |
warm_start_recentering | residual | residual measures the supplied point and raises mu only for measured complementarity; none restores pre-gh#606 behaviour. |
warm_start_bound_push, warm_start_bound_frac | 0.001 | Primal push away from bounds (as bound_push / bound_frac). |
warm_start_slack_bound_push, warm_start_slack_bound_frac | 0.001 | Same for inequality slacks. |
warm_start_mult_bound_push | 0.001 | Floor on bound multipliers. |
warm_start_mult_init_max | 1e6 | Upper clamp on the bound multipliers the warm-start initializer fills or reconstructs (0 = no cap). An unseeded equality-multiplier block is reconstructed by the cold path’s least-squares solve and capped by constr_mult_init_max instead. |
warm_start_entire_iterate | no | Ipopt’s GetWarmStartIterate switch. Registered so an ipopt.opt parses; yes is refused (gh#606) — POUNCE has no such TNLP surface. Use warm_start_init_point=yes, which carries the primal point and all multiplier blocks. |
warm_start_same_structure | no | Ipopt’s “same structure as the previous solve” switch. Registered; yes is refused for the same reason. Structure reuse across solves is the FERAL backend pool’s job (e.g. solve_nlp_batch(share_structure=True)). |
The default start mu is unchanged. The fix above is in how the supplied
multipliers are reconciled with the slacks, not in a lower default barrier:
lowering mu for every warm start is a trajectory change for points that are
stale or distant, where a larger start mu is the safer choice.
Starting-point conditioning
Three options displace the starting point before the barrier solve, and one turns the automatic retry that uses them on or off. All are off by default except the retry, which fires only after a solve has already failed. Full rationale and the measurements behind the defaults: Conditioning the starting point.
| Option | Default | Meaning |
|---|---|---|
infeasibility_perturbed_start_retry | yes | Rung 3 of the second-opinion ladder: on Infeasible_Problem_Detected, Invalid_Number_Detected or Restoration_Failed, re-solve once from a displaced start. Promoted only on Solve_Succeeded / Solved_To_Acceptable_Level. |
start_point_perturbation | 0.0 | Relative displacement scale·(1 + |x_i|)·u_i, u_i uniform on [-1, 1), clipped into bounds. 0 disables. Non-finite entries are repaired to a finite in-bounds value first. |
start_point_perturbation_seed | 0 | SplitMix64 seed for that displacement — no clock, no address, no thread identity, so the same seed gives the same point on every platform. |
start_point_conditioner | none | none, or adam to run a first-order warm-up on f(x) + ρ‖violation(x)‖² and start from where it lands. |
start_point_conditioner=adam reads three more. They are ignored unless
it is set, and their defaults are KRONOS’s published stage-0 values
(Ahmed & Hasan 2026, see
Acknowledgments).
| Option | Default | Meaning |
|---|---|---|
adam_warmup_iters | 200 | Iteration budget. Adam’s step is size-capped near the learning rate, so this buys about iters × learning_rate units of travel per coordinate. |
adam_warmup_learning_rate | 5e-2 | Step size. Because Adam normalizes by its second-moment estimate this is nearly the per-coordinate step in the model’s units, so set it against the size of the variables, not the derivatives. |
adam_warmup_penalty | 10.0 | ρ on the squared violation. Trades objective against feasibility during the warm-up only. The fixed, unscaled default is the likeliest cause of the measured tail on badly-scaled models — the first knob to move if the warm-up hurts. |
The warm-up is guarded: if it does not reduce the merit it hands back
the original point unchanged. It is off by default because across 40
problems POUNCE already solves it cut the median iteration count to
0.83× while raising the total 1.62×, on the strength of two
outliers (palmer1c 71 → 1023). A median win with a 14× tail is an
option, not a default.
Barrier-parameter (μ) strategy
The barrier parameter μ controls the inner subproblem’s relaxation of
complementarity. The two strategies are monotone (default — geometric
schedule) and adaptive (quality-function oracle picks each μ from the
current iterate’s complementarity). See
μ-strategy for when to switch.
The default depends on the Hessian. monotone is the registered
default, but hessian_approximation=limited-memory changes what an unset
mu_strategy means (gh#746, matching Ipopt’s IpAlgBuilder.cpp:1059):
| How L-BFGS was selected | Unset mu_strategy resolves to |
|---|---|
You asked for hessian_approximation=limited-memory (CLI, add_option, minimize(..., hessian_approximation=...)), even though a hessian is defined | adaptive |
The Python Problem object defines no hessian, so POUNCE fell back to L-BFGS itself | monotone — POUNCE pins it, because the fallback is not a request for a barrier schedule and adaptive measurably hurts warm starts |
| Exact Hessian | monotone |
An explicit mu_strategy always wins. On a 10-variable Rosenbrock with L-BFGS
the two rows differ as 117 iterations (adaptive) against 121 (monotone), so
the rule is visible as an iteration count. Because mu_strategy_fallback
defaults to on while mu_strategy is unset (gh#748), a stalled solve under
either default is retried once under the other. The Python path’s monotone
pin counts as unset for this purpose — it is marked as POUNCE’s choice, not
the caller’s (gh#990) — so the retry is on there too; an explicit
mu_strategy or mu_strategy_fallback=no turns it off. The solve report does not
record the strategy that was resolved; pin mu_strategy yourself when you
need the run to be reproducible across call paths.
| Option | Default | Meaning |
|---|---|---|
mu_strategy | monotone | monotone (Fiacco–McCormick schedule) or adaptive (oracle-driven). |
mu_oracle | quality-function | Adaptive oracle: quality-function / loqo / probing. |
mu_init | 0.1 | Seed value for μ at the first iterate. |
mu_min | 1e-11 | Floor on μ; the solver stops decreasing past this. In both μ strategies the effective floor is capped at `compl_inf_tol· |
mu_max | 1e5 | Cap on μ (adaptive mode). When set explicitly it overrides the mu_max_fact initialization. |
mu_max_fact | 1e3 | Initializes mu_max as mu_max_fact · curr_avrg_compl at the first iterate (adaptive mode). |
mu_target | 0.0 | Stop target for μ in monotone mode. |
mu_linear_decrease_factor | 0.2 | κ_μ in μ ← min(κ_μ · μ, μ^θ_μ). |
mu_superlinear_decrease_power | 1.5 | θ_μ in the same formula. |
barrier_tol_factor | 10.0 | Inner-subproblem tolerance scales as barrier_tol_factor · μ. |
tau_min | 0.99 | Floor on the fraction-to-the-boundary parameter τ = max(tau_min, 1 − μ); a step may cover at most τ of the distance to a bound. Read by both μ strategies (and by the restoration sub-solve). |
sigma_max | 1e2 | Upper clamp on σ chosen by the quality-function oracle. |
sigma_min | 1e-6 | Lower clamp on σ (raising this to 1e-2 can break a stair-stepping stall on some problems). |
adaptive_mu_globalization | obj-constr-filter | Adaptive-mode globalization: kkt-error, obj-constr-filter, or never-monotone-mode. |
Quality-function oracle (adaptive-μ details)
These are only consumed when mu_strategy=adaptive and
mu_oracle=quality-function. Defaults mirror upstream
IpQualityFunctionMuOracle::RegisterOptions.
| Option | Default | Meaning |
|---|---|---|
quality_function_norm_type | 2-norm-squared | Norm used to aggregate KKT components inside q(σ): 1-norm, 2-norm, 2-norm-squared, max-norm. |
quality_function_centrality | none | Centrality penalty term: none, log, reciprocal, cubed-reciprocal. |
quality_function_balancing_term | none | Balancing penalty when complementarity ≪ infeasibilities: none or cubic. |
quality_function_max_section_steps | 8 | Cap on golden-section iterations when picking σ. |
quality_function_section_sigma_tol | 1e-2 | Width tolerance in σ-space terminating the golden-section search. |
quality_function_section_qf_tol | 0.0 | Relative flatness tolerance on q(σ) terminating golden section. |
Adaptive-μ globalization
Tuning the safeguards that fall back to monotone-μ mode when the
adaptive oracle stops making progress. Defaults mirror upstream
IpAdaptiveMuUpdate::RegisterOptions.
| Option | Default | Meaning |
|---|---|---|
adaptive_mu_safeguard_factor | 0.0 | LOQO safeguard floor on the oracle’s μ candidate. |
adaptive_mu_monotone_init_factor | 0.8 | Multiplier on avrg_compl when seeding monotone mode after a bailout. |
adaptive_mu_restore_previous_iterate | no | Restore the latest free-mode iterate when switching to fixed mode. |
adaptive_mu_kkterror_red_iters | 4 | Window length for the kkt-error globalization history. |
adaptive_mu_kkterror_red_fact | 0.9999 | Required relative KKT-error reduction over that window. |
adaptive_mu_kkt_norm_type | 2-norm-squared | Norm used to score the iterate in adaptive globalization decisions. |
adaptive_mu_max_free_returns | -1 | Cap on returns to free-μ mode after entering monotone mode; -1 is unlimited (upstream). POUNCE extension (#749). |
adaptive_mu_budget_pin_fraction | 0.75 | Fraction of an explicitly set max_cpu_time/max_wall_time after which the strategy finishes monotone; 1 disables. Inert without a time budget. POUNCE extension (#753). |
Hessian approximation (hessian_approximation)
Which second-derivative information the algorithm works from. Four values; the first two are Ipopt’s, the last two are POUNCE extensions with no upstream counterpart.
| value | needs from the model | when |
|---|---|---|
exact (default) | eval_h — real second derivatives | the fast path whenever they exist |
limited-memory | nothing beyond the gradient | no second derivatives available |
finite-difference | the analytic Jacobian plus a sparsity pattern | no eval_h, but the Jacobian is exact and sparse |
partitioned | the declared Jacobian sparsity | structured models, above all direct collocation |
exact and limited-memory are documented under
L-BFGS initialization
and throughout this page. The other two are below.
finite-difference
Recovers the exact Lagrangian Hessian by differencing the analytic Jacobian along a set of probe directions, rather than approximating it from step/gradient pairs. Where L-BFGS presents the linear solver a dense low-rank correction over a diagonal, this hands it the model’s real sparse Hessian, so a structured problem factors like one.
The cost is one Jacobian evaluation per probe group, per rebuild. Three options control how many groups there are and how often you pay for them:
| option | default | values | what it does |
|---|---|---|---|
fd_hessian_pattern | declared | declared, jacobian | where the sparsity pattern comes from |
fd_hessian_coloring | cpr | cpr, star | how columns are grouped into probes |
fd_hessian_reuse_tol | 0 | ≥ 0 | relative movement below which the previous Hessian is reused |
fd_hessian_pattern. declared uses the TNLP’s declared Hessian
structure — the structure call only, never the values, so it is
available to any model that cannot evaluate second derivatives; every
.nl declares one through AMPL’s AD. jacobian derives it from the
Jacobian pattern alone, as the union over rows of
supp(∇g) ⊗ supp(∇g). That is a strict superset of the true pattern,
so it is safe — a superset costs extra probe groups, never a wrong
answer — but it is not free: on benchmarks/large_scale laptime it is
146 267 nonzeros against the true 28 000. There is deliberately no mode
that guesses a subset, which would silently drop curvature.
fd_hessian_coloring. A star colouring needs fewer groups than
Curtis-Powell-Reid — on the Jacobian-derived laptime pattern, 42 where
CPR needs 76 — and its recovery is algebraically exact, so it looks like
the better default and is not. A forward difference is not an exact
Hessian-vector product: it carries a third-derivative cross term into
each row, and CPR’s distance-2 property forbids the two columns that
term needs from sharing a group, while a star colouring does not.
Measured on laptime, star over the Jacobian pattern takes 404
iterations to a wrong objective where CPR takes 38 to the right one;
over the sparser declared pattern both take 30. Group size is not the
cause — declared/star has the largest groups of the four and is fine.
Use star only on a sparse declared pattern, and measure.
fd_hessian_reuse_tol. A rebuild costs one Jacobian evaluation per
group, so skipping it when nothing has moved is the cheapest saving
available. Both the primal iterate and the multipliers are tested, not
just x: the Lagrangian Hessian is ∇²f + Σⱼ yⱼ ∇²cⱼ, so a cached
Hessian is stale the moment y moves even if x has not — and the
endgame of an interior-point solve is full of short steps with moving
duals. 0, the default, rebuilds every iteration.
finite-differencewill produce a Hessian for a model whose second derivatives do not exist, because it never asks for them.hessian_approximation=exactrefuses such a model; this one does not. That is the point of it, and also the risk: the answer is only as good as the Jacobian is differentiable.
partitioned
Keeps one small dense quasi-Newton block per element function — the objective, and each constraint row, whose support is a row of the Jacobian — and assembles them into a genuine sparse Hessian. The linear solver then sees the model’s real block structure instead of the diagonal the limited-memory low-rank path presents. Intended for structured problems where second derivatives are unavailable but the Jacobian sparsity is declared; direct-collocation trajectory optimization above all.
| option | default | values | what it does |
|---|---|---|---|
partitioned_elements | per-constraint | per-constraint, blocks | how the Lagrangian is split into elements |
partitioned_update_type | sr1 | sr1, bfgs | update formula applied to each element block (bfgs: objective element only; constraint elements always SR1) |
partitioned_structure | declared | declared, jacobian | split each element along the model’s declared Hessian sparsity and keep only its declared entries |
partitioned_max_element | 64 | ≥ 1 | widest element that keeps a dense block |
partitioned_block_size | 64 | ≥ 1 | target block width, elements=blocks only |
partitioned_curvature_cap | off (inf) | > 0 | cap on one update’s movement. Leave off |
partitioned_elements. per-constraint gives each block a
multiplier-independent target and assumes nothing about variable
ordering, at the cost of as many blocks as there are constraints.
blocks is the partition of Asprion, Chinellato and Guzzella: a direct
collocation transcription orders its variables by stage, so the
Lagrangian Hessian is close to block diagonal in contiguous blocks and
the block count is the stage count. When the model declares its Hessian
structure, the blocks come from that pattern instead — see
partitioned_structure below. Set partitioned_block_size to
what one stage contributes (states × collocation points, plus controls)
— too small and the block misses genuine intra-stage coupling, too large
and each block carries more parameters than its one curvature pair per
iteration can determine. The ordering assumption is reported rather than
trusted: POUNCE_PARTITIONED_ORACLE prints the fraction of the exact
Hessian’s Frobenius mass that falls inside the block pattern.
partitioned_update_type. SR1 is the default because an individual
constraint is not convex. Damped BFGS would force every element model
PSD, the solve would then scale it by a multiplier of either sign, and
the indefiniteness would never reach the inertia correction.
elements=blocks defaults to damped BFGS instead, since there the
element is the Lagrangian restricted to a block, which is the object
an interior-point method wants a positive-definite model of.
partitioned_structure. A constraint row is linear in most of its
support and nonlinear in a few small independent groups (a collocation
row couples a state to the control at the same collocation point and to
nothing else), so its Hessian is a handful of tiny blocks inside a
dense-looking k × k pattern. One secant pair per iteration cannot
determine a dense block, and the rank spent on pairs that do not couple
appears as spurious entries (up to 1e2 against a true Hessian whose
largest entry was 1e-3, on a Radau-collocated batch reactor).
declared (default) reads the Hessian sparsity the model already
declares for eval_h — structure only, nothing is evaluated — gives
each connected group its own element, and drops coordinates the pattern
never couples. A model that declares no Hessian structure (a Python
problem object without hessian, for instance) takes the jacobian
behaviour, because an absent declaration is not evidence of linearity.
Within each group only the declared entries are kept. A connected
group is usually not complete — a collocation row’s group is a star, the
control coupled to each state at the same point with no state–state and
no state-diagonal entries — and a dense SR1 or BFGS term writes curvature
into every one of those structurally zero positions. Weighted by
multipliers of either sign, those entries gave the assembled Hessian the
wrong inertia, and the interior-point method spent the solve on a
regularization δ_w decaying by a third per iteration. An element whose
declared pattern is incomplete is therefore updated by the
pattern-constrained minimum-change secant update (Toint’s sparse PSB),
from zero, whichever partitioned_update_type is set; on a star that
determines the element Hessian from a single pair, so sr1 and bfgs
coincide there. partitioned_update_type still governs elements whose
declared pattern is complete and models without a declared pattern.
Radau-collocated batch reactor (gh#989 item 5; discopt model, .nl
export, the issue’s starting profile), iterations:
| N | exact | limited-memory | partitioned | bfgs | blocks |
|---|---|---|---|---|---|
| 25 | 12 | 17 | 13 (was 14) | 13 (was cap) | 13 (was 88) |
| 50 | 17 | 16 | 15 (was 22) | 15 (was cap) | 15 (was 122) |
| 100 | 16 | 16 | 15 (was 54) | 15 (was cap) | 15 (was 139) |
| 400 | 18 | 17 | 21 (was 74) | 21 (was cap) | 21 (was cap) |
With blocks, a declared pattern whose connected components all fit in
partitioned_block_size supplies the blocks: those components are the
Lagrangian Hessian’s exact diagonal blocks, whatever order the writer put
the variables in (an AMPL .nl puts nonlinear variables first, so
contiguous ranges are not stages there). A model whose pattern has a wider
component keeps the contiguous partition — chunking a component cuts
declared coupling, and on benchmarks/large_scale laptime (one
3 280-variable component at N = 80) it hit the iteration cap where the
contiguous blocks take 166.
Under partitioned_update_type=bfgs, constraint elements take SR1
anyway (gh#989): damped BFGS is applied only to the objective element (and
to the Lagrangian blocks of elements=blocks). A positive-semidefinite
model of an indefinite ∇²c_j, weighted by a multiplier of either sign,
gave the assembled Hessian the wrong inertia, and on a model without a
declared Hessian structure (or with partitioned_structure=jacobian) bfgs
hit the iteration cap on the batch reactor at every mesh size. Now it
matches sr1 there (N = 25: 16 and 16; N = 50: 23 and 23 iterations, the
same as sr1 because that model’s objective is linear).
partitioned_max_element. An element with k nonzeros costs
k(k+1)/2 stored reals, so one wide constraint row would dominate the
memory. Elements wider than this degrade to a diagonal approximation
satisfying the weak secant condition rather than being dropped, so a
separable objective is still represented exactly and a coupled one
approximately.
partitioned_curvature_cap — off, and every finite value measured was
worse than off, non-monotonically so. On benchmarks/large_scale
laptime at N=80 with max_iter=1200 (true optimum 65.462928):
cap=1e1 exits ErrorInStepComputation at 1071 iterations and
65.518586; cap=1e2 hits the iteration limit at 67.202124; cap=1e6
hits it at 80.398129; off converges in 559 iterations at 65.462802.
Rejecting an update is selective — it drops exactly the elements whose
curvature is moving fastest and leaves those blocks stale while their
neighbours update, and the resulting inconsistent Hessian costs more
than a uniformly noisy but coherent one. Kept as a knob so the effect
can be re-measured against a different element decomposition. Do not
enable it without measuring.
Limited-memory Hessian (L-BFGS) initialization
Under hessian_approximation=limited-memory the Hessian model is
B = σ I + V Vᵀ − U Uᵀ. The rank-2 corrections come from the curvature
history; σ is the diagonal they are built on, and
limited_memory_initialization chooses the formula for it.
| Option | Default | Meaning |
|---|---|---|
limited_memory_initialization | scalar1 | Formula for σ: scalar1 (σ = sᵀy/sᵀs), scalar2 (σ = yᵀy/sᵀy), scalar3 (arithmetic mean of the two), scalar4 (geometric mean), constant (σ = limited_memory_init_val), history-max (the scalar1 formula over the whole history, largest wins — POUNCE extension, #818). |
limited_memory_init_val | 1.0 | σ on the first iteration, before any curvature pair exists — and every iteration under constant. |
limited_memory_init_val_min / _max | 1e-8 / 1e8 | Clamp applied to σ however it was computed. |
The default matches Ipopt’s scalar1. Note that it changed in #677:
every earlier release used scalar2 and ignored this option entirely —
it was registered but never read, so setting it had no effect and no
warning. The two differ by σ_scalar2/σ_scalar1 = (yᵀy·sᵀs)/(sᵀy)², which
is ≥ 1 by Cauchy–Schwarz and grows without bound as the curvature pair
becomes ill-conditioned, so on a badly scaled problem they are far
apart. If you are reproducing results from an older POUNCE, set
limited_memory_initialization scalar2.
history-max (#818)
Every upstream rule reads the newest curvature pair. history-max
is POUNCE’s, and reads all of them: it applies the scalar1 formula to
each pair in the history window and keeps the largest.
σ is the curvature the model assigns to every direction outside the
span of the stored pairs — the rank-2 corrections say nothing there.
sᵀy/sᵀs is a Rayleigh quotient of the true Hessian along one step, so
when the problem’s curvature spans orders of magnitude it is an
arbitrary sample of the spectrum. Land near the small end and B
understates the curvature of every unexplored direction by up to
cond(H); the step is longer than the truth by that factor and the
line search has to claw it back. Over-stating σ costs an iteration;
under-stating it costs a backtracking sweep and then feeds a tiny s
back into the history. history-max takes the conservative reading.
Nothing is lost on the directions the model does know: the last rank-2
update enforces B s_last = y_last whatever B0 was.
This is not the default, because it wins where the window cannot
span the spectrum and loses where it can. On #818’s separable quadratic
f(x) = Σ (sᵢxᵢ − 1)² with s = 10^linspace(0, 4, n) (iterations to
converge, max_iter 2000, * = did not converge):
| case | scalar1 | history-max |
|---|---|---|
n = 4, cond 1e4 | 12 | 29 |
n = 4, cond 1e8 | 18 | 21 |
n = 4, cond 1e12 | 61 | 28 |
n = 8, cond 1e4 | 396 | 166 |
n = 8, cond 1e8 | 2000* | 2000* |
n = 8, m = 10 | 34 | 66 |
Reach for it when a limited-memory solve is stalling with a long
backtracking sweep every iteration on a problem you know is badly
conditioned, and the memory (limited_memory_max_history) is small
relative to the number of variables. A running maximum over the whole
solve rather than over the window was measured too and is worse than
scalar1 at every size — it never comes back down, so a stiff early
transient stays in B0 long after the iterate has left it.
recalc_y under L-BFGS
A quasi-Newton dual step is computed from an approximate Hessian, so an
L-BFGS solve can settle a feasible primal and still fail to drive dual
infeasibility to tolerance. recalc_y yes re-estimates the equality and
inequality multipliers by least squares on every iteration whose
constraint violation is below recalc_y_feas_tol (default 1e-6),
side-stepping the approximation. Each firing costs one extra
augmented-system solve.
Ipopt’s option text says this is used by default with a quasi-Newton
Hessian. POUNCE does not enable it by default, because doing so
regressed 7 of 57 fixtures on the L-BFGS leg — re-estimating y every
iteration also overwrites Newton multipliers that were converging
perfectly well. Reach for it when dual infeasibility oscillates without
descending while the objective and the primal have already settled; that
is the shape it fixes.
σ cannot be observed directly, but the symptom of a badly chosen one is
recognisable: a search direction much larger than the problem’s scale,
primal step sizes collapsing to 1e-3 or below, primal infeasibility
that barely moves, and dual infeasibility climbing by orders of magnitude
while the objective drifts.
Backtracking trial steps and alpha_red_factor_min
When the filter rejects a trial step the line search shortens it and
tries again. Upstream reduces by a fixed factor,
alpha *= alpha_red_factor (default 0.5), so reaching a step of size
α takes log(1/α) trial points — each one a full objective
evaluation.
That is cheap when the Hessian model is roughly right and ruinous when
it is not. Under hessian_approximation=limited-memory the model’s
scale can be wrong by orders of magnitude in any direction its
curvature pairs do not span, so the acceptable step can be α ≈ 1e-6
and every iteration spends 19–20 trial points walking down to it
(#818).
POUNCE therefore picks the next trial step by fitting the quadratic through the barrier objective’s value and slope at the current iterate and its value at the rejected trial, and jumping to that quadratic’s minimizer — the textbook safeguarded backtracking step. Two clamps bound it:
| Option | Default | Meaning |
|---|---|---|
alpha_red_factor | 0.5 | Upper bound on one reduction: the trial sequence still contracts at least as fast as upstream’s. |
alpha_red_factor_min | 0.05 under limited-memory, = alpha_red_factor (off) under an exact Hessian | Lower bound on one reduction, so a badly shaped objective cannot collapse α to noise in a single trial. |
Acceptance is unchanged — this only decides which α is tried next, so
no step it proposes can be accepted that the fixed sequence would have
rejected. Set alpha_red_factor_min equal to alpha_red_factor to
restore upstream’s fixed geometric sequence; an explicit value is
honoured on both Hessian paths.
The default is off for the exact-Hessian path because a Newton step’s
length is meaningful — the acceptable α is normally within a couple of
halvings of 1, so interpolation buys nothing — and because enabling it
there was measured to move 3 of the 156 fixture-legs in
scripts/sweep-fixtures.sh — eigena2 27 → 31,
issue_508_infeasible_gap_1em4 441 → 580 to the same certificate, and
an objective digit on hs13_bigstart — with nothing on that leg
improving in exchange.
It engages only once the fixed sequence has already spent six trial
points. The interpolation treats a long line search, and it is only
harmless where the line search is long: one that accepts in two or three
trials never had the problem, and interpolating into it swaps a step
length the filter was about to accept for a different one — a trajectory
change bought for nothing. The threshold is not an option; it is the
constant ALPHA_INTERP_MIN_TRIALS in
crates/pounce-algorithm/src/line_search/backtracking.rs, which carries
the sweep that chose it, every row against one baseline. Interpolating
from the first trial instead moves 12 fixture-legs, three of them to a
status the baseline did not have; gating at six moves four, one of them
a gain (cresc4, Restoration_Failed/1323 → Solve_Succeeded/281) and
none of them a loss.
Six rather than five because of two measurements. deb7 changes verdict
at a gate of 5 (Error_In_Step_Computation → Restoration_Failed) and
keeps it at 6; and one line search in the race_starts regression suite
reaches exactly 5–6 trial points, so a gate of 5 interpolates into it and
reroutes a whole multistart race, costing 32% more solver evaluations on
one model there.
Where it loses, and what to do about it. Over a 32-cell sweep of the
#818 model family (n ∈ {4, 8, 12, 20} × cond ∈ {1e2, 1e4, 1e8, 1e12} ×
limited_memory_max_history ∈ {6, 10}) the same 22 cells converge before
and after — no cell gains or loses Solve_Succeeded — but it is not
free: of those 22, 13 take fewer iterations, 5 are unchanged and 4 take
more. The worst is n = 8 at cond 1e4 with memory 10, 188 → 580, a 3.1×
regression to the same answer.
If a limited-memory solve stalls where it used to crawl, the first thing
to try is limited_memory_max_history 10 — the cells this change
does not fix are bounded by the quality of the quasi-Newton model, not
by the trial sequence, and a wider window is what moves that bound. The
8-variable cond-1e8 case is the example: at memory 6 it exhausts
max_iter — reaching f ≈ 9e-13 and x to 6e-7 relative, so it has
found the answer but cannot certify it — and at memory 10 it converges
in 61 iterations. The general escape hatch is alpha_red_factor_min
equal to alpha_red_factor, which collapses the clamp and restores
upstream’s sequence exactly.
One cell of that sweep, n = 8 at cond 1e12 with memory 6, read as a
status regression during review — Diverging_Iterates at 352, at every
alpha_red_factor_min and every gate measured. The cause was not the
line search: the divergence guard was pronouncing unboundedness on a
watchdog trial iterate, a point the line search had already rejected
and was holding a snapshot to revert to. That is fixed separately. The
cell still does not converge — it reports Error_In_Step_Computation at
521 iterations — but it no longer claims divergence, and it gets to a
better objective than upstream’s sequence does in four times as many
iterations (6.4e-11 against 2.8e-10 at max_iter).
When the line search fails anyway: limited_memory_ls_failure_restarts
When no trial step is acceptable, either the point is bad —
infeasible, and the restoration phase is exactly the right tool — or the
direction is, because W is a quasi-Newton model carrying curvature
the iterate has left behind. Upstream has one answer for both, because
restoration is the only fallback it has.
At an already-feasible point that answer is a no-op. The restoration NLP
minimizes the constraint violation and there is none to minimize, so it
wanders at θ ≈ 1e-13 and reports Restoration_Failed. On the deb7
fixture under limited-memory the solve stalls at inf_pr ≈ 1e-12 with
inf_du ≈ 1e5, enters restoration at a point feasible to 8e-13, and
spends 340 of its 1242 iterations there. On an unconstrained model θ
is identically zero, so restoration cannot move at all.
| Option | Default | Meaning |
|---|---|---|
limited_memory_ls_failure_restarts | 0 (off) | How many times a line-search failure at a feasible point may drop every curvature pair but the newest and retry, before handing off to restoration. 0 is upstream’s unconditional hand-off. |
The newest pair is kept rather than the history cleared, because σ is
read off the history and an empty one falls back to
limited_memory_init_val — a bare 1.0, which throws the model back to
its first-iteration state on a problem whose curvature the solver has by
now measured. This is L-BFGS-B’s col = 0 restart, adapted.
It is a rung and not a refusal: it fires only where restoration has nothing to reduce, it runs after the acceptable-point decline (so a point that already passes the acceptable tolerances is still reported rather than re-anchored and continued), and every path that reached restoration before still reaches it once the rung is spent. The bound is structural as well as counted — the re-anchor gives up once the history is down to one pair, so a second failure at the same iterate falls straight through. It has no effect under an exact Hessian, which has no curvature history to re-anchor.
It ships off, and it is not what fixes #818. The safeguarded
interpolation above is; the rung was measured separately on top of it
and does not pay for itself across the fixture corpus. Turning it on
moves six lbfgs legs: deb7 715 → 610 iterations and
issue_508_infeasible_gap_1em4 79 → 76 in its favour, against
eigena2 91 → 98, pooling_rt2stp 295 → 307 and
infeasible_square_scaled_1em4 24 → 26 — the last two being models the
shipped configuration leaves exactly where main had them, so the rung
introduces those two regressions rather than inheriting them. It stays
in the tree, and stays documented, because the failure mode it treats is
real and reproducible: on a model that stalls at inf_pr ≈ 1e-12 with
inf_du large, limited_memory_ls_failure_restarts 1 is worth trying
before concluding the solve is stuck.
Note that setting it — to any value, 0 included — opts the solve out
of the automatic Solved_To_Acceptable_Level re-solve ladder, like
every other option in TERMINATION_POLICY_OPTIONS. Leaving it unset is
therefore not the same as passing limited_memory_ls_failure_restarts 0.
ℓ₁ penalty-barrier wrapper options
These tune the degenerate-NLP wrapper described in Running Solves. All are default-tuned and rarely need overriding:
| Option | Default | Meaning |
|---|---|---|
l1_exact_penalty_barrier | no | Run the ℓ₁-exact penalty-barrier wrapper unconditionally. |
l1_fallback_on_restoration_failure | no | Retry with the wrapper only when the standard solve fails. |
l1_penalty_init | 1.0 | Initial penalty weight ρ. |
l1_penalty_max | 1e6 | Maximum penalty weight before declaring infeasibility. |
l1_penalty_increase_factor | 8.0 | Multiplier applied to ρ each outer iteration. |
l1_penalty_max_outer_iter | 8 | Maximum penalty outer iterations. |
l1_slack_tol | 1e-6 | Fallback slack tolerance — see below. |
l1_steering_factor | 10.0 | Steering-rule factor for ρ escalation. |
Each ρ pass is a full solve. The reported iteration_count (iter_count in
Python) is the total over all passes, iterations holds every pass’s
rows, and the solve report’s statistics.passes lists one entry per pass —
rho, iterations, the model’s own constraint_violation, and the pass’s
status (gh#987). Other final_* fields describe the last pass, which
produced the returned point.
The wrapper solves an augmented problem, c(x) − p + n = target with
p, n ≥ 0, whose equality rows the slacks satisfy to machine precision
by construction. So neither the residual the inner solve converged nor
the slack sum Σ(p + n) is the violation of the constraints you
declared — that is |pᵢ − nᵢ| per row, and at the barrier’s interior
both slacks stay positive where their difference is zero.
Since gh#794 the wrapper therefore measures the original model’s rows
and bounds at the returned point, and judges that violation by the
tolerances you set — tol for a strict Solve_Succeeded,
acceptable_tol for Solved_To_Acceptable_Level, scale-relative in
both cases — before the ρ loop stops or the honest-infeasibility upgrade
fires. l1_slack_tol survives only as the fallback for a model whose
rows cannot be evaluated at the returned point, and Σ(p + n) keeps its
other job as the Byrd-Nocedal-Waltz steering signal for ρ escalation.
That measurement is in your model’s own units, so it is reported in
final_unscaled_constr_viol (and folded into
final_unscaled_kkt_error), which is where the
statistics contract puts original-unit residuals. The
scaled family — final_constr_viol, final_kkt_error — carries the
same number only when no row scaling is active, the case in which the
two families are defined to agree anyway. Under an active
nlp_scaling_method the scaled fields keep the augmented problem’s own
residuals, because converting the measurement into that space needs
row-scale factors the wrapper does not have once the inner solve has
returned. The exit status is unaffected either way: it is decided
from the measurement directly, not from these fields. If you are
checking ℓ₁ feasibility programmatically, read
final_unscaled_constr_viol.
NLP Presolve
POUNCE’s TNLP-wrapper presolve pipeline runs before the IPM starts. It tightens variable bounds, drops redundant rows, and (optionally) eliminates square auxiliary-equality sub-systems structurally. All are off by default — set the master switch first:
presolve=yes applies equally to CLI solves and to every
IpoptApplication::optimize_tnlp library solve; callers no longer need to
wrap a callback TNLP manually.
The wrapper postsolves before finalize_solution, so callback payloads remain
in the submitted TNLP’s original variable and constraint space. Bare callback
TNLPs do not expose an expression provider. The pounce-rs builder can opt in
through Problem::constraint_expression.
| Option | Default | Meaning |
|---|---|---|
presolve | no | Master switch for the whole presolve layer. Off → wrapper is a no-op. |
presolve_bound_tightening | yes | Phase 1 — Andersen-style bound propagation from linear rows. |
presolve_redundant_constraint_removal | yes | Phase 2 — drop linear constraints already implied by current bounds. |
presolve_linear_eq_reduction | no | Phase 6 — eliminate variables determined by linear equality rows (see below). |
presolve_licq_check | yes | Phase 3 — detect rank-deficient equality blocks before the IPM starts. |
presolve_licq_action | warn | What to do on degeneracy: warn (just report) or auto_l1 (turn on ℓ₁). |
presolve_warm_z_bounds | yes | Phase 4 — warm-start bound multipliers when bounds get tightened by Phase 1. |
presolve_bound_mult_init_val | 1.0 | Value used by Phase 4 for those warm-start hints. |
presolve_max_passes | 3 | Fixed-point iteration cap across the bound-tightening passes. |
presolve_print_level | 0 | Per-pass verbosity (0 silent, 5 per-pass, 8 per-transformation). |
Linear-equality variable elimination (Phase 6)
presolve_linear_eq_reduction=yes is the only pass that removes
columns. It reads the model’s linear equality rows and eliminates the
variables they determine, iterating to a fixed point so chains propagate:
- a variable whose declared bounds are equal becomes a constant;
- a singleton row
a·x = bpins its variable atb/a; - a two-variable row
a₁·x + a₂·y = bsubstitutes one variable for the other,x := α·y + β. There is no anchoring requirement: a row linking two otherwise-free interior variables — an arc equality, aReferencealias, a unit-conversion link — aggregates away, which is the case the auxiliary-equality pass cannot reach because it only solves determined square blocks.
Rows that collapse to 0 = 0 under the accumulated substitutions are
dropped as structurally redundant.
A row written with a constant on the left — x0 − 2·x1 + 3 = 3 — is
eligible on the same terms as x0 − 2·x1 = 0. The .nl reader folds a
constant row body into the row’s bounds when the file is read, so the pass
sees an ordinary linear equality.
Every eliminated variable’s bounds are transferred onto its survivor, so
the reduced box is never looser than the original. finalize_solution
lifts the primal back to the original variable order and recovers a
multiplier for each consumed row, so .sol / JSON solution blocks keep the
original model’s shape and can still be read positionally by AMPL or Pyomo.
Three things to know before turning it on:
-
Dual attribution. A transferred bound’s multiplier comes back on the variable that declared the bound, not on the survivor that inherited it. The plan records where each reduced bound came from, and postsolve rescales the multiplier by the substitution’s coefficient
α— and moves it to the other side of the box whenα < 0, since a negative coefficient turns a lower bound into an upper one. On a model with a single active transferred bound the reported duals match a no-presolve solve exactly.Where the survivor’s own bound and a transferred bound are active at the same point, the split between the two multipliers is genuinely non-unique — the reduced problem has one where the full problem has two — and the pass leaves the whole multiplier on the survivor. That is a valid KKT point, but it is not the split a no-presolve solve happens to report. The same holds for a variable pinned by a singleton row
a·x = bwhose value lands on one of its own bounds: the row multiplier absorbs it.A practical consequence for
.solreaders, unchanged by any of the above: the writer omits exact zeros from suffix blocks (it always has), so a variable whose bound multiplier is zero gets noipopt_zL_out/ipopt_zU_outentry at all rather than an entry of zero. Code that indexes those suffixes must treat a missing index as zero — as it already must for any variable whose bound multiplier lands exactly on zero. Row multipliers are unaffected: the dual block is dense and comes back at the original row count. -
Bounds the reduced problem never saw. Re-attribution can only move a multiplier the solver reported, and sometimes there is none. The transfers can leave a survivor’s reduced box as a single point, and a variable with equal bounds is a fixed variable, which the solver drops — so it comes back with no bound multiplier at all even though the cluster it stands for is sitting on a bound that needs one. Postsolve fills that in: whatever stationarity residual the recovered row multipliers cannot close is a bound multiplier that was never reported, and it goes on the declared bound the point is actually resting on — the survivor’s own where that is the active one, otherwise the column the bound was borrowed from, through the same
αrescale as above. A residual with no active declared bound to carry it is left alone rather than parked somewhere that would break complementarity.One column is deliberately outside this: one the model declares fixed (
x_l == x_u). The solver drops those as parameters whether or not the reduction runs, and reports no multiplier for them either way, so nothing here changes what they report. -
Failing closed. If the equality system is contradictory, the pass stands down entirely and hands the model to the solver untouched, rather than being the first and only voice to call a model infeasible. The same goes for a model whose every column is determined: a zero-variable problem is not a shape worth handing the IPM.
It is off by default because it changes the variable count, which the
sensitivity and reduced-Hessian paths index against the original .nl.
(The CLI already disables presolve entirely when those are requested.)
LP and convex QP take a different route to the same reduction. Those
models never reach Phase 6 — the CLI dispatches them to pounce-convex
before any presolve wrapper is built — but they are not left unreduced.
pounce-convex has its own presolve, on by default, and it now performs
the two-variable aggregation as part of that catalog, sharing this
planner rather than restating it. So the reduction is the same; only the
switch differs (qp_presolve=no / presolve=no turns it off there, and
presolve_linear_eq_reduction does not apply). See
LP/QP routing.
The two agree on dual attribution as well: a transferred bound’s multiplier is reported on the column that declared the bound, not on the survivor that inherited it. They get there differently — Phase 6 records during planning where each reduced bound came from, while the convex path reads the leftover reduced cost at postsolve and hands it to whichever column is sitting on its own bound, because it also has inequality rows and its own bound-tightening layer to account for. Where the survivor’s own bound is active as well, both leave the multiplier on the survivor; the split is genuinely non-unique there and either answer is a valid KKT point.
Feasibility-based bound tightening (Phase 1b)
Interval-arithmetic propagation through nonlinear constraint
expression DAGs (see FBBT). Available today for
.nl-loaded problems via NlTnlp; other TNLP sources opt out
silently.
| Option | Default | Meaning |
|---|---|---|
presolve_fbbt | no | Master switch. Requires presolve=yes and an ExpressionProvider. |
fbbt_tol | 1e-6 | Minimum per-variable bound improvement to keep iterating. |
fbbt_max_iter | 10 | Outer-sweep cap. |
fbbt_max_constraints | 0 | Per-sweep cap on constraints inspected (0 = unlimited). |
Auxiliary-equality preprocessing (Phase 0)
A separate set of options controls the structural elimination pass documented in Auxiliary-Equality Preprocessing:
| Option | Default | Meaning |
|---|---|---|
presolve_auxiliary | no | Master switch for the Phase-0 structural elimination pass. |
presolve_auxiliary_coupling | safe | Which coupling classes are eligible: none / safe / aggressive. |
presolve_auxiliary_tol | 1e-8 | Residual tolerance for accepting a candidate block solve. |
presolve_auxiliary_max_block_dim | 8 | Largest block the lightweight Newton solver will attempt (larger blocks rejected in v1). |
presolve_auxiliary_wall_time_fraction | 0.1 | Fraction of the solver’s wall-time budget the pass is allowed to spend. |
presolve_auxiliary_diagnostics | no | Emit the diagnostics summary via the journalist after Phase 0 runs. |
FERAL backend tuning
linear_solver=feral (the default — see
Commonly used options) is configurable
through thirteen feral_* options. Defaults are tuned for the IPM
workload and rarely need changing; reach for these when profiling a
specific problem. Each also falls back to a matching POUNCE_FERAL_*
environment variable when left unset on the OptionsList (see
Environment overrides).
| Option | Default | Meaning |
|---|---|---|
feral_ordering | auto | Fill-reducing ordering method (see table below). auto lets feral’s adaptive dispatcher pick per-matrix; auto_race measures symbolic fill and keeps the smallest. Collocation, optimal-control and PDE-in-time models should set metis. |
feral_ordering_preprocess | auto | Ordering-stage preprocessing. ldlt_compress is the Duff-Pralet symmetric matching plus quotient-graph compression (MUMPS ICNTL(12)=2): each matched pair — typically a variable and the constraint it pivots with — is ordered as one super-variable, so the fill-reducing method never splits a 2×2 pivot. none orders the KKT pattern directly. auto (default) lets FERAL’s shape predicate decide; the choice it made is reported as linear_solver.last_ordering_preprocess. The trade-off is large and model-dependent: on GasLib-40 transient control at 112k variables with metis, none factors 7× faster than the ldlt_compress that auto picks (8.8e9 against 6.5e10 flops) but pivots so poorly — about 100k delayed columns per factorization, tiny pivots, quality escalations — that the 56k solve takes 190 iterations and 234 s against 94 and 100 s. A diagnostic lever, not a tuning default. |
feral_pivtol | 1e-8 | Relative Bunch-Kaufman partial-pivoting threshold u. Analog of ma27_pivtol / ma57_pivtol. Smaller → sparser L, faster, less stable; larger → more 2×2 blocks, denser, more stable. LAPACK’s textbook maximum-stability value is 0.5. |
feral_refine | conditional | Whether FERAL runs its own iterative refinement inside every back-solve. The default is conditional and this column cannot express it (gh#909): yes on the exact-Hessian path — the default path — and no under hessian_approximation=limited-memory. --print-options prints yes, the exact-Hessian value, because a single registered default is all it has; read the carve-out here. gh#710 (reported as gh#698 observation 5) turned refinement off for the NLP solver, as on every direct linear solver Ipopt ships and on POUNCE’s own MA57 — but it registered no while the reader consulted only the user’s setting, so an unset option kept FeralConfig’s own yes and the exact path never changed. gh#909 found the disagreement and kept the behaviour, because gh#710’s measurement below was taken under limited-memory and that is precisely the path the carve-out still turns off. On the exact path, measured across all 79 fixtures on both legs, refinement is the better default: ten fixture-legs move, no status flips, and the balance favours it (issue_508_infeasible_gap_1em4 441 → 245 iterations, square_flowsheet_resto 54 → 47, against one loss at 31 → 32). Off-corpus NARX_CFy goes 400 → 630 iterations without it, and on eigena2 it is what makes the superlinear tail robust — 3.4e-10 with, 5.4e-09 without. FeralConfig’s own default stays yes for callers such as pounce-convex’s SOS/QP solvers that refine their own system but never call increase_quality. Refinement belongs on the unreduced Newton system — that is PdFullSpaceSolver’s loop, capped at max_refinement_steps and accepting at residual_ratio_max = 1e-10 — not on the condensed system a backend factorized, because the condensation destroys information as mu -> 0 (Wachter-Biegler 3.10). Turning it on nests FERAL’s loop inside that one, and FERAL’s convergence target is hard-wired to eps*sqrt(n); on a large ill-conditioned KKT that target is unreachable, so the inner loop runs to its cap on every back-solve chasing digits the caller discards. It was on unconditionally through 0.10.0 because FERAL’s ZeroPivotAction::ForceAccept can leave real residual against the system it factorized, and without it the gh#590 badly-scaled LP exits RestorationFailed — but Ipopt’s answer to a factorization that cannot deliver is IncreaseQuality (escalate the pivot threshold and refactorize), and that rung was unimplemented in the FERAL backend. It is now, so refinement no longer has to stand in for it. On the 126028-dimension laptime KKT under limited-memory, one binary, three runs back to back: 68.9 s on, 18.8 s off, against MA57’s 10.7 s (back-solve 54.6 s -> 8.2 s). Set yes to restore pre-0.11 behaviour on a problem that needs it. |
feral_refine_steps | 10 | Maximum correction steps FERAL’s inner iterative refinement may take on a single back-solve, when feral_refine is on. An upper bound, not a step count: refinement still exits early on its own convergence test, so lowering this only truncates the solves that were going to run long. 0 leaves refinement enabled but caps it at zero corrections — that still costs the residual evaluation, so use feral_refine=no to switch refinement off outright. Reach for a small cap (1) on very large, badly conditioned KKT systems where the interior-point tail spends most of its wall clock inside refinement rather than the factor (gh#710) — but check the answer, not just the clock: sweeping the fixture corpus at 1 moves 15 of 118 legs and loses two, deb7 (exact) from SolveSucceeded to ErrorInStepComputation and cresc4 (limited-memory) from SolveSucceeded to InfeasibleProblemDetected, while others improve. A per-problem lever, not a global one. Ignored when feral_refine=no, which is what hessian_approximation=limited-memory selects (gh#909) — on that path set feral_refine=yes explicitly before either knob has any effect. On the exact-Hessian path refinement is on, so both knobs are live there without setting anything. |
feral_refine_target | 0 | Residual level at which FERAL’s inner refinement is skipped entirely, as a relative 2-norm ‖b − A·x‖₂ / ‖b‖₂ on the unrefined solve. 0 (the default) disables the check, so every back-solve refines. Where feral_refine_steps truncates the refinement on every solve alike, this one decides whether it runs, per solve — which is the difference that matters, because FERAL’s RefineOptions carries a step cap and no target and so converges to eps*sqrt(n), the tightest residual the arithmetic admits, while PdFullSpaceSolver accepts a step at residual_ratio_max = 1e-10 on the unreduced system. On the 126028-dimension laptime KKT the unrefined solve already lands in the 1e-11 band against a hard-wired target of 7.9e-14, and setting 1e-8 takes the solve from 67.2 s to 28.7 s (back-solve 53.4 s → 18.6 s). Unlike feral_refine=no this still refines the solves that need it: the gh#590 noise-floor LP (data scale 1e11) keeps its certificate at 1e-8, which no feral_refine_steps value achieves. It is still a per-problem lever, not a global one — at 1e-8 the fixture corpus moves 17 of 118 legs, losing eigena2 (limited-memory) from SolvedToAcceptableLevel to ErrorInStepComputation, eigenb2 from SolveSucceeded to SolvedToAcceptableLevel, and taking pooling_rt2stp (exact) from 128 to 413 iterations for the same objective, while autocorr_bern55-06 and cresc4 improve. Reach for it when the timing report shows LinearSystemBackSolve dominating on a large KKT, and check the answer. Ignored when feral_refine=no, which is what hessian_approximation=limited-memory selects (gh#909) — on that path set feral_refine=yes explicitly before either knob has any effect. On the exact-Hessian path refinement is on, so both knobs are live there without setting anything. Upstream fix: feral#190. |
feral_increase_quality | yes | Whether FERAL may escalate its factorization when the interior-point refinement stalls. A deliberate deviation from Ipopt, and a two-sided one (gh#850). Ipopt calls IncreaseQuality when PdFullSpaceSolver’s refinement stalls, and MA57 answers by raising pivtol toward pivtolmax — strictly more conservative each time, so a raised threshold can only make later factorizations safer. FERAL’s ladder instead changes which pivots are taken, and it persists identically across every later factorization including a restoration sub-solve’s, so it reroutes solves in both directions. It costs two whole solves on square_flowsheet_resto — the exact leg goes Optimal/99 to RestorationFailed/131 and the limited-memory leg goes Optimal/178 to the 3000-iteration cap. It also buys accuracy nothing else supplies (a 12-variable watchdog model ends at obj = 3.7e-6 with it and 3.42 against f* = 0 without) plus 15–25% of the iterations on several fixture-legs, which is why it stays on. Note the ladder is documented as scaling-then-pivot-threshold but the scaling rung is unreachable as POUNCE ships (feral takes it only under ScalingStrategy::Identity; POUNCE defaults to Auto), so every escalation you see is a pivot_threshold bump, the first 1e-8 → 1e-6. A milder ladder is not the remedy: every static feral_pivtol in {1e-6, 3.16e-5, 4.2e-4, 1e-2, 0.5} loses that limited-memory leg from iteration 0 and only 1e-8 solves it. Set no to recover a model this rung costs. How often it fired is reported as quality_escalations. Only the NLP path consults it; the convex engines never call increase_quality. |
feral_increase_quality_retry | yes | Re-solve once with feral_increase_quality=no when a solve that actually escalated ends in Restoration_Failed, Maximum_Iterations_Exceeded or Infeasible_Problem_Detected (gh#857). Rung 4 of the second-opinion ladder, appended last, and the only rung whose gate is a measurement of the failing solve rather than a property of the options it ran under: it requires quality_escalations >= 1, so a solve that never escalated is provably not a candidate and pays nothing. Maximum_Iterations_Exceeded opens no other rung — the answer to a budget exit is normally a bigger budget — and this is the exception, because when the escalation is what walked the trajectory into the wall a bigger budget re-runs the same wall. It is what turns square_flowsheet_resto’s limited-memory leg from the 3000-iteration cap back into Optimal/178 without the user having to know feral_increase_quality exists. The result is promoted only if it returns Solve_Succeeded or Solved_To_Acceptable_Level. Cost: one extra solve on a run that was already going to report failure — including a deliberately small max_iter, which on an escalating model now spends a second budget before reporting. Set no to hold a capped run to exactly the budget it was given. Infeasible_Problem_Detected is on the list because the escalation can manufacture a false one — the same fixture and leg exits that way on linux/x86_64 rather than at the cap — and that costs one confirming re-solve on models that really are infeasible; the escalation gate bounds it to the ones that escalated. Not run by the multi-start paths, where a failed start is routine. |
square_system_recovery | yes | POUNCE extension. The last stage of the second-opinion ladder, for a square (free variables = equality rows, no inequalities), constant-objective model whose solve failed and whose applicable rungs all failed: solve c(x) = 0 by bounded Levenberg–Marquardt on the constraint Jacobian alone, factored by FERAL. Promoted to Solve_Succeeded only if the returned point is inside the declared box, has an exactly zero objective gradient, and passes the IPM’s feasibility test at zero multipliers (tol against the violation under the row scaling the IPM computes for the model, constr_viol_tol unscaled); reports λ = 0, z = 0. Opens on every ladder verdict including a non-escalating Maximum_Iterations_Exceeded. At most min(max_iter, 200) LM iterations; honours max_wall_time, max_cpu_time and the intermediate callback’s stop request, any of which ends it without a promotion. A promotion produces no sIPOPT step or reduced Hessian (no IPM KKT factor); the CLI warns. Set no to keep the interior-point verdict. |
feral_cascade_break | (unset) | Tri-state. Unset → inherit feral’s Phase B default (CB on with bounded delayed-pivot catchment). yes records explicit intent (no behavioural change). no reproduces pre-Phase-B behaviour by surfacing DelayBudgetExceeded on non-root cascade victims. |
feral_fma | no | Dispatch dense kernels through fused multiply-add intrinsics. Roughly 2× throughput on aarch64 / x86_v3, at the cost of per-pivot rounding drift that trips more WrongInertia checks. Turn on when kernel throughput dominates and the IPM tolerates a noisier inertia signal. |
feral_singular_pivot_floor | 1e-20 | Pounce’s analog of MA57’s CNTL(2). After a successful factor, the smallest accepted D-block pivot magnitude (scaled space) is compared against this absolute floor; if it falls below, the factor is reported Singular so the IPM bumps δ_w. 0 disables. |
feral_inertia_pivot_floor | unset (n · eps) | Pivot magnitude below which a mismatching inertia count is treated as noise rather than as evidence (#540). Consulted only once the negative-eigenvalue count already disagrees with what the IPM asked for: if the smallest accepted pivot (scaled space) is under this floor, the factor is reported Singular instead of WrongInertia, so δ_c — the perturbation that repairs a rank-deficient constraint block — is applied before the δ_w ladder starts multiplying by 8 per retry. A sub-floor pivot is necessary but not sufficient (#1003): the reroute also needs a count that is too low (which δ_c raises), a factor feral flagged (floored or threshold-failed pivots), or sub-floor pivots isolated from the spectrum (none in [floor, 100·floor)). A surplus negative on a clean factor whose small pivots grade into the spectrum stays WrongInertia, since δ_c cannot remove a negative eigenvalue. Because it only ever fires on a factor the caller was already going to reject, it cannot turn a usable factorization into a failure. Necessarily larger than feral_singular_pivot_floor, which governs factors that are unusable outright. Unset, the floor is the dimension-aware n · eps (n = order of the factored KKT; ~5e-14 on a few-hundred-order KKT, since #592) — not a fixed 1e-12. Setting the option pins an absolute floor at every dimension. 0 disables. |
feral_min_par_flops | 1e8 | Flop threshold above which a supernode subtree is dispatched to a parallel worker (feral#19). Lower → dispatch more aggressively (0 fires on every multi-child tree at/above N_PAR_MIN supernodes); a very large value rejects all tree-level parallelism. Only matters when feral’s internal parallelism is active; no effect on a serial factor. |
feral_static_pivoting | (unset) | Tri-state. Factor with static pivoting (SSIDS-style delayed pivots disabled). Unset → inherit feral’s delayed-pivot default. yes runs every supernode as the root does — a failing pivot is force-accepted in place with iterative refinement recovering the residual — breaking the delayed-pivot cascade that can turn one factorization into tens of seconds (feral#8; the emfl050 case in #254). feral’s analog of MA57’s cntl[4]. no keeps delayed pivoting on. Deliberately not coupled to max_wall_time — the accuracy/speed trade is the caller’s to set per solve. |
feral_ordering variants
All six concrete and adaptive options live under the same string
option. feral_ordering also falls back to the
POUNCE_FERAL_ORDERING environment variable when not set on the
OptionsList.
| Value | Strategy |
|---|---|
auto | Default. Adaptive dispatcher: picks a concrete method per matrix from cheap pattern features. Branches: very-large-and-sparse (n > 100 000, avg degree < 5) → AMD; everything else → AMF. It never selects nested dissection: the branch that used to route n > 10 000 to MetisND now goes to AMF (feral#67, #73). One symbolic pass; right for most models, and wrong for the collocation class below. |
auto_race | Race-based dispatcher: runs full symbolic factorization on AMD and MetisND (feral ≥ 0.18; AMD, MetisND, ScotchND, KahipND before) and keeps the smallest factor_nnz. ~2× a single symbolic pass, paid once per problem (symbolic factorization is cached across numeric refactorizations with the same pattern). It measures fill, not time, and fill has mispredicted the wall-clock winner repeatedly in feral’s own measurements, so a pinned method you have timed beats it. |
amd | Approximate Minimum Degree (Amestoy/Davis/Duff). Pins AMD regardless of problem shape; robust default for IPM workloads. Best for very-large-and-sparse cases that the adaptive dispatcher already routes here. |
amf | Approximate Minimum Fill (HAMF4 variant of Amestoy 1999). Strong on small-and-sparse populations (n ≤ 10 000); aggregate fill ≈ 0.87× AMD on feral’s IPM small-sparse inventory. |
metis | feral-metis multilevel nested dissection. Pin it for collocation, optimal-control and PDE-in-time models (see below). Tends to produce squarer fronts than AMD on banded / nearly-1D structure. |
scotch | feral-scotch nested dissection. Similar regime to METIS; alternative when METIS is unavailable or for cross-validation. |
kahip | feral-kahip flow-based nested dissection with K1 preprocessing. Ties METIS on fill geomean at 4–6× per-call symbolic cost. Reach for it only when ND fill matters and per-call cost is amortized. |
linear_solver.last_ordering can differ from what you pinned. FERAL
uses an AMD leaf below a nested-dissection switch size — an internal
FERAL parameter, not a POUNCE option — of 120 rows for SCOTCH and 200 for
METIS and KaHIP (feral 0.18 defaults): on a small matrix
feral_ordering=scotch (or metis, kahip) still reports
last_ordering: "amd", because the nested-dissection recursion bottoms out
in AMD at once. This is not a failed option. The ordering you pinned only
shows up in last_ordering once the matrix is larger than the switch.
min_abs_pivot / max_abs_pivot are in FERAL’s equilibrated space.
They are pivots of the matrix after FERAL’s own row/column equilibration,
not of the KKT matrix in your model’s units: max_abs_pivot reads 3.0
whether a Hessian entry is 2 or 2e6, even at nlp_scaling_method=none.
Use them to compare factorizations of the same model (near-singularity,
min_abs_pivot collapsing between iterations), not to read off the
curvature scale.
When in doubt: leave feral_ordering at the default, with one
exception.
Collocation, optimal-control and PDE-in-time models: set metis
A model transcribed on a mesh — direct collocation of an ODE/DAE, a
discretized optimal-control problem, a PDE stepped in time — has a KKT
matrix that is a space-by-time grid, sparse in both directions. Nested
dissection is the ordering built for that shape, and the default never
picks it (see auto above). Set it explicitly:
pounce model.nl feral_ordering=metis
Measured with feral 0.18, whose separator-refinement fix (feral#203) is
what makes it pay — before that fix metis was slower per iteration
than the default on the same models:
| Model | Variables | Default | metis | Speedup |
|---|---|---|---|---|
| GasLib-40 transient control, 24 h hourly | 55 950 | 209 s, 122 it | 100 s, 80 it | 2.1× |
| GasLib-40 transient control, 24 h 30 min | 111 894 | 546 s, 63 it | 165 s, 47 it | 3.3× |
laptime (Radau collocation, large_scale) | 58 014 | 136 s, 380 it | 70 s, 349 it | 2.0× |
On GasLib-40 the gain grows with the mesh: per iteration it is 1.4× at
56k and 2.5× at 112k, and metis also takes fewer iterations. The
503 502-variable, 72-hour case ran in 1 h 30 min this way. scotch and
kahip land within about 10% of metis on these models once feral
0.18 is in.
It is a per-class choice, not a better default. Across the
Maros-Meszaros QP set metis is 5% slower (geomean
over the problems that take at least a second) and loses 7–78% on the
grid QPs (AUG2DQP, AUG2DCQP, CONT-*); on benchmarks/large_scale
it is 13–22% slower on poisson, optcontrol (single-state, linear
dynamics) and sparseqp. It wins big on CVXQP2_L / CVXQP3_L
(2.9× / 1.9×). If you are unsure which class a model is in, time one
run each way: the answer is usually a factor of two, not a few percent.
For other hard models that look linear-solver-bound, feral_ordering auto_race is a cheap first probe, but it ranks arms by fill, so confirm
its pick against a pinned run before relying on it.
Caller-supplied ordering (External)
Beyond the string variants above, a structure-aware caller can inject a
precomputed permutation the generic AMD/METIS pass cannot see — a
block-triangular / Schur ordering (Parker, Garcia & Bent,
arXiv:2602.17968) or a tearing ordering from equation-oriented
decomposition. Because a permutation is a vector it cannot travel through
the string feral_ordering option; supply it programmatically instead:
- Python:
Problem.set_ordering(perm)(andget_ordering()/clear_ordering()) — see the Python guide. - Rust:
IpoptApplication::set_external_ordering(perm).
perm is a 0-based, new-to-old permutation (perm[k] is the original
index that becomes index k) whose length must equal the augmented KKT
system dimension (variables + slacks + constraint duals), not the
problem’s n. FERAL validates it as a bijection and fails the
factorization with an error on a wrong length or duplicate — a valid but
poor ordering only costs fill/time, never correctness. This maps to
FERAL’s OrderingMethod::External (feral#107) and honors only the default
FERAL backend.
Block-structured KKT
Some models are arrowhead: independent blocks that touch each other only through a small set of shared columns or linking rows. Security-constrained OPF (one block per contingency, sharing the base-case dispatch), two-stage stochastic programs (one block per scenario, sharing the first stage) and multi-cell process models all have this shape. Told where the blocks are, POUNCE factorizes them in parallel and couples them through a dense complement over the border, instead of factorizing one monolithic KKT.
This is exact. It is a permutation plus block elimination of the same symmetric indefinite system, with inertia recovered by Haynsworth additivity — not a decomposition algorithm, and nothing about convexity is assumed. The solve takes the same iterates it would have taken: on the measurements below the iteration counts and objectives are identical to the monolithic path, digit for digit.
| Option | Default | Meaning |
|---|---|---|
block_structure_file | (unset) | Path to a declaration for the .nl path. First line n m, then n variable labels and m constraint labels, whitespace-separated, negative for the shared ones. POUNCE maps model indices onto the KKT itself. |
kkt_block_detect | no | Look for the structure in the assembled KKT rather than being told it. A degree heuristic — see the limits below. |
kkt_block_min_size | 256 | Refuse a partition whose median block is narrower than this many KKT columns. 0 honors any partition. |
kkt_block_restoration | yes | Use the same partition inside the restoration sub-IPM. |
Honored on the FERAL + exact-Hessian path (the default path). Anywhere else —
another linear solver, hessian_approximation=limited-memory, a declaration
that does not fit the problem — POUNCE falls back to the standard solver with a
warning and solves normally. A declaration can cost you time; it cannot cost
you an answer.
Declaring the structure
Three routes, all carrying the same information:
-
From Python, in model coordinates — one label per variable and one per constraint, negative for shared:
problem.set_block_structure(var_blocks, con_blocks)POUNCE maps these onto the KKT layout itself, which is the point: the layout depends on which variables were fixed and removed and on how constraints split into equalities and inequalities, none of which a modelling layer can see. (
set_kkt_block_structuretakes KKT-space indices directly and exists for callers that already have them; prefer the model-space form.) -
From a
.nlfile, withblock_structure_file— the same two label lists in a text file, for the CLI and any AMPL-style pipeline. -
From discopt, which resolves a model’s
set_block/mark_couplingannotations into this form and passes them through (solve_nlp_from_model(..., block_structure="auto")).
When it pays, and when it does not
The block path’s only win is parallelism: a good ordering already finds arrowhead structure, so the monolithic factorization is not doing redundant work. Against that win sits a fixed per-block cost — one symbolic analysis, one task, one border tail — so blocks have to be wide enough to be worth dispatching. Measured on a 32-block arrowhead, factorization seconds full-space → declared:
| columns per block | full-space | declared | ratio |
|---|---|---|---|
| 60 | 0.0060 s | 0.0195 s | 0.31× |
| 125 | 0.0133 s | 0.0231 s | 0.57× |
| 250 | 0.0260 s | 0.0264 s | 0.98× |
| 500 | 0.0534 s | 0.0343 s | 1.56× |
| 1 000 | 0.1244 s | 0.0500 s | 2.49× |
| 2 000 | 0.2725 s | 0.0843 s | 3.23× |
kkt_block_min_size is the guard for that: below it the declaration is refused
and logged, so a small model does not silently take the slower path. Raise it
if your blocks are wide but the border is large; set 0 to override the check.
The border matters for the same reason — its complement is dense, so a partition whose border runs to thousands of columns costs more than the factorization it replaces, and POUNCE refuses those too.
On a corrective N-1 AC-SCOPF built from pglib case1354_pegase, where the
blocks are 13 601 KKT columns wide and the border is 259:
| contingencies | variables | factorization | wall | iterations |
|---|---|---|---|---|
| 16 | 54 859 | 5.66 → 1.31 s (4.3×) | 18.1 → 10.0 s | 43 = 43 |
| 32 | 106 491 | 11.52 → 2.42 s (4.8×) | 37.7 → 21.1 s | 49 = 49 |
| 64 | 209 755 | 10.72 → 2.82 s (3.8×) | 125.1 → 111.9 s | 87 = 87 |
The end-to-end factor is smaller than the factorization factor because factorization is only part of a solve — on these models roughly a third, with function evaluation the larger share. That is arithmetic, not a disappointment: speeding up a third of the work by 4× is a 1.3–1.8× solve.
Restoration
kkt_block_restoration (default yes) carries the partition into the
restoration sub-IPM. It applies unchanged there because POUNCE reduces the
restoration KKT onto the original system before factorizing, so the same labels
describe it. On the infeasible 64-contingency run above, restoration’s
factorizations fall from 35.0 s to 11.4 s — three times the main solve’s share.
One caveat worth knowing: block elimination cannot pivot across the
block/border split, so the factorizations are not bit-identical to the
monolithic ones. The main solve reproduced its trajectory exactly on every
model measured; restoration, which is called precisely because the model is
locally infeasible, can take a slightly different path to the same verdict. Set
no to hold restoration to the monolithic factorization.
Detection, and why a declaration beats it
kkt_block_detect=yes looks for the structure by ranking columns by degree and
peeling the densest. It works when the shared columns stand out — on
case118_ieee they have degree 130 against a median of 6 — and fails when
they do not: on case1354_pegase the shared generator columns have degree
~50, no more than many ordinary columns, and detection reports 581 blocks over
a 7 301-column border, which the border guard then refuses. The model knows
which columns are shared; a degree heuristic only sometimes does. Use detection
to discover whether a model has structure worth declaring, and a declaration to
exploit it.
What gets reported
When the path engages, the solve report’s linear_solver.blocks object (and
Python’s info["linear_solver"]["blocks"]) carries n_blocks, border_dim,
largest_block, the number of block factorizations and the seconds they took;
restoration’s appear under linear_solver.restoration.blocks. If that object
is absent, the partition was refused — the log says why.
Parallel function evaluation
On a large .nl model, evaluation — not factorization — is usually where the
time goes. Measured on a 54 859-variable AC-SCOPF with
print_timing_statistics, function evaluation was 70% of the solve and the
Lagrangian Hessian alone 52%, against 14% for the factorization. POUNCE’s .nl
evaluators now use the machine’s cores for all three of the expensive walks:
| Walk | Split over | Measured (case1354_pegase, K=16) |
|---|---|---|
| Lagrangian Hessian | Hessian colors | 4.890 s → 1.114 s |
| Constraint Jacobian | row groups | 1.214 s → 0.193 s |
| Constraint values | rows | 12.3 ms → 1.8 ms per call |
Whole solve on that model: 9.81 s → 4.68 s, and with a declared block structure (see Block-structured KKT) 18.1 s → 4.68 s.
The results are bit-identical to the serial ones, which is the property worth relying on: the iteration counts and objectives do not move, because each walk contributes the same terms in the same order. It is not “the same to within a tolerance” — the parallel Hessian keeps every color’s contributions in ascending row order precisely so the sums are identical term by term. So this changes how long a solve takes and nothing else.
It switches on automatically above a measured size threshold per walk, and below it POUNCE stays serial because the dispatch would cost more than it saves. Nothing to configure.
What to expect, and what not to. The Jacobian and constraint walks split over rows, so their parallelism is bounded only by the core count (~6× at 8 threads on the models measured). The Hessian’s is bounded by the number of colors in its compressed Hessian: 28 colors on this AC-SCOPF gives 4.9×, while a banded model needing only 3 colors reaches 1.7× and cannot do better however many cores are free. A model whose Hessian colors well will gain more than one that does not.
| Variable | Meaning |
|---|---|
POUNCE_NL_PARALLEL_EVAL=0 | Force the serial walks. |
POUNCE_NL_PARALLEL_EVAL=1 | Force the parallel walks regardless of size. |
Since both settings compute identical values, these are levers for measurement
— 1 is how the thresholds were measured — and for pinning a suspected
parallelism bug, not correctness switches. Models built for the browser
(wasm32) always take the serial walks.
One path is not parallel yet: the shared-CSE arm, which forwards one prelude for the whole constraint block (models whose rows share large common subexpressions). It is not slower than before — it simply has not been split.
Environment overrides (FERAL and debug gates)
A handful of knobs are reachable through environment variables. The
feral_* numerics knobs read their POUNCE_FERAL_* variable only as a
fallback when the matching option is left unset on the OptionsList — set
the option (per solve, recordable, discoverable via the debugger’s opt
command) in preference to the env var (process-wide, invisible to the solve
report). The debug gates below have no option equivalent; they exist purely
to switch on extra diagnostic output.
FERAL numerics fallbacks
Each maps one-to-one to a registered option in FERAL backend tuning. Prefer the option; the env var is the fallback for callers with no OptionsList (some tests, legacy embeddings).
| Variable | Option |
|---|---|
POUNCE_FERAL_ORDERING | feral_ordering |
POUNCE_FERAL_ORDERING_PREPROCESS | feral_ordering_preprocess |
POUNCE_FERAL_SCALING | feral_scaling |
POUNCE_FERAL_PIVTOL | feral_pivtol (deprecated bare FERAL_PIVTOL also accepted) |
POUNCE_FERAL_REFINE | feral_refine |
POUNCE_FERAL_REFINE_STEPS | feral_refine_steps |
POUNCE_FERAL_REFINE_TARGET | feral_refine_target |
POUNCE_FERAL_INCREASE_QUALITY | feral_increase_quality |
POUNCE_FERAL_CASCADE_BREAK | feral_cascade_break |
POUNCE_FERAL_FMA | feral_fma |
POUNCE_FERAL_SINGULAR_PIVOT_FLOOR | feral_singular_pivot_floor |
POUNCE_FERAL_INERTIA_PIVOT_FLOOR | feral_inertia_pivot_floor |
POUNCE_FERAL_MIN_PAR_FLOPS | feral_min_par_flops |
POUNCE_FERAL_STATIC_PIVOTING | feral_static_pivoting |
These variables are parsed by feral::env (feral#176), which accepts the
same spellings the option parser does — including scientific notation, so
POUNCE_FERAL_MIN_PAR_FLOPS=1e8 sets the documented default rather than
being silently discarded, as it was before feral 0.17.0 when pounce parsed
these with a bare str::parse. A value that is out of range for the
target type is clamped to the maximum; a value that cannot be parsed at
all, or that fails the knob’s stated requirement (e.g. a negative pivot
floor), is refused with a one-time warning on stderr and the default is
used. A refused variable never silently changes numerics.
FERAL_PARALLEL (legacy, no POUNCE_ prefix) forces feral’s internal
factor serial or parallel process-wide — 0/off/false/no to force
serial, 1/on/true/yes to force parallel, and unset to leave
feral’s own platform-derived default alone. The force-on direction is the
only override available to CLI, Python and NL callers on a host where
that autodetection is wrong (feral falls back to sequential when the
rayon pool fails to build); the first-class per-backend lever,
FeralConfig.parallel, is the Rust solver API, not an option.
Debug and diagnostic gates
These switch on extra diagnostic emission for a specific subsystem. Most
emit at debug level under a pounce::* tracing
target, so setting the gate alone is not enough — pair it with a matching
RUST_LOG (e.g. RUST_LOG=pounce::mu=debug) or the output stays filtered.
Presence-only unless a value is noted; they are diagnostic aids, not part
of the stable interface, and may change between releases.
| Variable | Subsystem (RUST_LOG target) | Emits |
|---|---|---|
POUNCE_DBG_AMU | pounce::mu | Adaptive-μ per-iteration state (θ, f, oracle inputs). |
POUNCE_DBG_ORACLE | pounce::mu | μ-oracle probe-guard decisions (probe-Newton → restoration requests). |
POUNCE_DBG_QF | pounce::mu | Quality-function μ-oracle σ search (floor, current μ). |
POUNCE_DBG_QF_AGGR | pounce::mu | Quality-function aggregate step/complementarity terms per σ. |
POUNCE_DBG_QF_SWEEP=<iter> | pounce::mu | Dumps the full quality-function σ sweep at the given iteration number. |
POUNCE_DBG_DELTA | pounce::algorithm | Primal-dual search direction δ per iteration. |
POUNCE_DBG_LS=1 | pounce::linesearch | Filter line-search / backtracking acceptance trace (must equal 1). |
POUNCE_DBG_PERT | pounce::linsol | Inertia-perturbation handler decisions (WRONG_INERTIA, δ_w escalation). |
POUNCE_DBG_PD_TAGS | pounce::linsol | Primal-dual full-space solver dependent-block tag changes. |
POUNCE_DBG_KKT_DUMP=<path> | pounce::linsol | Writes the tagged KKT matrix to <path>. |
POUNCE_DBG_KKT_DUMP_SKIP=<n> | — | Skip the first <n> factorizations before honoring POUNCE_DBG_KKT_DUMP. |
POUNCE_DUMP_KKT=<path> | pounce::linsol | Writes the standard augmented-system KKT matrix to <path>. Deprecated — prefer --dump kkt:<iter-spec> (see pounce --help). |
POUNCE_DBG_RESTO | pounce::algorithm, pounce::restoration | Restoration entry trace and the augmented restoration-system stats. Canonical spelling; the legacy POUNCE_RESTO_DBG (restoration-system stats only) is a deprecated alias. |
POUNCE_DBG_RESTO_CYCLE | pounce::algorithm | Restoration no-progress cycle-detector relative-step metrics. |
POUNCE_DBG_RESTO_INIT | pounce::restoration | Restoration initial-point vectors. |
POUNCE_DBG_RESTO_KAPPA | pounce::restoration | Restoration κ_resto convergence-guard evaluation. |
POUNCE_DBG_RESTO_LOCINF | pounce::restoration | Restoration local-infeasibility verdict inputs. |
POUNCE_DBG_TAPE_STATS | — (stderr) | AD tape counts after parsing an .nl model. Printed straight to stderr; no RUST_LOG needed. |
POUNCE_DBG_CLASSIFY | — (stderr) | The detected problem class and the finding that produced it, next to the .nl header’s own nonlinearity census. This is the line to read when a model routed to a solver you did not expect. No RUST_LOG needed. |
POUNCE_DBG_CONSTDERIV | — (stderr) | Which of the three constant-derivative cases fired for each of the four *_constant hints — the proof, whether you asserted it, and whether the derivative is reused. No RUST_LOG needed. |
POUNCE_DBG_GONDZIO | — (stderr) | One line per convex (LP/QP/conic) solve: which driver ran, its iteration count, and how many Gondzio centrality correctors were attempted, how many accepted, and their mean step-length gain. Read it to tell whether qp_gondzio_corr is doing anything on your model — an attempted=0 line means the cone is not a pure orthant, or the option is 0. No RUST_LOG needed. |
POUNCE_DBG_NO_QUAD | — (no output) | Changes what runs, rather than emitting. Turns off quadratic recognition, so every .nl body keeps its expression tree and is evaluated through the AD tape rather than from stored constant structure — both the expanded read-out ½xᵀHx + aᵀx + c and, since gh#673, the factored one Σ wₖ(bₖᵀx + dₖ)² a sum of squared residuals keeps. This is the A/B switch the quadratic evaluator is measured with: if a model’s numbers move when it is set, the evaluator is the difference. Slower by construction, and larger in memory. It is not a general “pre-quadratic” switch — in particular the constant-derivative proofs behind the four *_constant hints read the same recognizer through the tree, so they resolve identically either way and POUNCE_DBG_CONSTDERIV=1 prints the same verdicts with it set. |
POUNCE_SIMPLEX_DEBUG | — (stderr) | Convex/LP-QP simplex pivoting trace. Printed straight to stderr; no RUST_LOG needed. |
Two already-documented gates round out the set: POUNCE_DBG_LLM and
POUNCE_DBG_VIEWER (see the debugger guide).
Logging and colored output
POUNCE emits structured logs and a colored iteration table through the
tracing ecosystem. Behavior is governed by
environment variables (not solver options), so they apply to the pounce
CLI, the C/Python frontends, and anything embedding the library.
| Variable | Values | Effect |
|---|---|---|
RUST_LOG | e.g. info, debug, pounce::restoration=debug | Log verbosity / per-target filtering. Default info. Logs go to stderr. |
POUNCE_LOG_FORMAT | text (default) · json | json emits line-delimited JSON on stderr (incl. the per-iteration pounce::iteration stream) for Studio / CI ingestion. |
NO_COLOR | set to any value | Disables ANSI color in the iteration table and logs (see https://no-color.org). |
CLICOLOR_FORCE | set to any value | Forces color in the logs even when stderr (where they go) is not a terminal; it also forces the summary’s red declared-violation line on stdout. |
Filtering by subsystem. Solver internals log under namespaced targets
— pounce::algorithm, pounce::linsol, pounce::mu, pounce::sqp,
pounce::linesearch, pounce::restoration, pounce::presolve,
pounce::py. For example, to trace only the restoration phase:
RUST_LOG=pounce::restoration=debug pounce problem.nl
Program output vs. logs. The iteration table, the final summary, and
--dump diagnostics are program output on stdout; diagnostic and
progress messages are logs on stderr. Redirecting one does not
affect the other:
pounce problem.nl > result.txt 2> solve.log
Color. The iteration table is colored with a tiger/rust theme:
restoration lines take a background that varies by restoration kind
(soft-stay → tan, soft-exit → amber, hard → deep rust), and the row text
shades from black toward red as the primal step length alpha shrinks
(stalling). Color is emitted only when stdout is a terminal; redirected
output and NO_COLOR get plain text with identical column alignment.
Machine-readable iterations. POUNCE_LOG_FORMAT=json turns the
per-iteration records into JSON on stderr:
POUNCE_LOG_FORMAT=json pounce problem.nl 2> iters.jsonl