Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

OptionMeaning
tolOverall convergence tolerance on the KKT error.
max_iterMaximum number of outer iterations.
print_levelConsole verbosity, 0 (silent) – 12 (maximum debug).
linear_solverKKT linear-solver backend: feral (default) or ma57 (needs a --features ma57 build). Any other registered name is refused. See below.
mu_strategyBarrier-parameter update strategy (monotone / adaptive).
solver_selectionRoute LP/convex-QP to the specialized convex IPM. See LP/QP Routing.
qp_presolvePresolve on the convex LP/QP path, and on the conic (convex QCQP) path with the cone rows protected (yes / no, default yes). See LP/QP Routing.
obj_scaling_factorConstant multiplier on the objective; negative maximizes. See below.
bound_relax_factorRelaxation 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_boundsProject 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.

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.opt spells out defaults, and dependency_detector=none asks 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_constant and jac_d_constant tell 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 .nl model’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, so hessian_constant=yes there 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 Problem callbacks, 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=1 prints which of the three fired for each of the four options.

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
OptionDefaultEffect
derivative_testnonenone / first-order / second-order / only-second-order.
derivative_test_perturbation1e-8Relative finite-difference step: `perturbation · max(1,
derivative_test_tol1e-4Flag 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_allnoList 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.

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, or 1/x is 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_naninf is 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 a cargo build --features ma57 build.

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 setsresult
backend knobs onlyerror, exit 2
backend knobs + any option POUNCE readswarning, solve continues
backend knobs at their registered defaultssilent, solve continues
no backend knobssilent, 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.

optiondefaultMA57 controlwhat it does
ma57_pivtol1e-8CNTL(1)Relative pivot threshold. Smaller pivots for sparsity, larger for stability.
ma57_pivtolmax1e-4Ceiling the solver may raise ma57_pivtol to when it needs a more accurate solve. Must be ≥ ma57_pivtol.
ma57_pre_alloc1.05Safety factor on the work-space MA57 suggests. Larger avoids a reallocation when the suggestion falls short.
ma57_pivot_order5ICNTL(6)Pivot-ordering strategy (05).
ma57_automatic_scalingnoICNTL(15)Let MA57 scale the matrix itself. See Scaling.
ma57_block_size16ICNTL(11)Block size for the Level-3 BLAS in MA57BD.
ma57_node_amalgamation16ICNTL(12)Node amalgamation parameter.
ma57_small_pivot_flag0ICNTL(16)1 moves small pivots to the end of the factorization instead of using them — efficient on a highly rank-deficient matrix.
ma57_print_level0MA57’s own printing: 0 silent, 1 errors, 2 +warnings, 3 +terse monitoring, ≥4 everything.
ma57_batched_backsolvenoPOUNCE 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_list was a correct reader with no callers, because every construction site went through Ma57SolverInterface::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_pivtolmax also now refuses a value below ma57_pivtol rather 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.

OptionDefaultMeaning
ma57_batched_backsolvenoyes 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 iterationcolumn at a timebatchedΔ
back-solve0.1256 s0.0854 s−32.0%
numeric factorization0.1443 s0.1711 s+18.5%
linear algebra total0.2796 s0.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‖²)
OptionDefaultMeaning
neg_curv_test_tol0.00 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-121e-11.
neg_curv_test_regyesWhether 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:

modeldefaultneg_curv_test_tol=1e-11
csfi2Solved_To_Acceptable_Level, 35 itSolve_Succeeded, 27 it
unbounded_cubicDiverging_Iterates, 290 itDiverging_Iterates, 61 it
cresc481 it90 it
infeasible_equalitiesInfeasible_Problem_Detected, 28 itsame, 37 it
unbounded_expError_In_Step_Computation, 27 itsame, 32 it
eigena226 it421 it
eigenb267 it960 it
autocorr_bern55-06Solve_Succeeded, 72 it, obj -2304.0000281042 it, obj -2288.000022
pooling_rt2stpSolve_Succeeded, 298 it, obj -3273.954992Solved_To_Acceptable_Level, 537 it, obj -3085.16078
deb7Solve_Succeeded, 154 itError_In_Step_Computation, 183 it
eigenb2 (L-BFGS leg)Solve_Succeeded, 56 itError_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.

OptionDefaultMeaning
neg_curv_escapes1How 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-memory it does nothing. The curvature it reads is B, and BFGS maintains B positive definite by construction, so the inertia test passes at δ_x = 0 and the escape declines. The L-BFGS leg of the fixture sweep still reports obj = 1 on 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.

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_viol is still checked against constr_viol_tol (default 1e-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 within acceptable_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_Exceeded where the count alone would have said Solved_To_Acceptable_Level.
  • It never looks at a solve that converges. A solve that reaches tol never 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 settledmaxᵢ |dᵢ| / (1 + |xᵢ|), over the x and s blocks, is at or below dual_divergence_retry_step_tol (default 1e-5); and
  • the unscaled Lagrangian-gradient norm is at or above dual_divergence_retry_du_floor (default 1e2).

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:

  1. the retry ends Solve_Succeeded;
  2. its unscaled KKT error and constraint violation are both within acceptable_tol;
  3. its unscaled KKT error is strictly better than the first attempt’s; and
  4. 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_tol scaled by max(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+05above 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:

rununscaled dualviolcomplratio
the #884 reproducer7.90e+041.1e-161.1e-091.5e-14
deb7 + L-BFGS + rung, macOS9.90e+018.0e-134.65e+004.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=no disables the retry outright. The detector still runs and the report still records what it saw.
  • dual_divergence_retry_step_tol=0 holds 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:

modelPOUNCE defaultPOUNCE kappa = 1Ipopt (default)
robot_aMaximum_CpuTime_Exceeded, 8.173304Optimal, 1.0431952, 112 itMaximum_Iterations_Exceeded, 8.173304
robot_bMaximum_CpuTime_Exceeded, 15.484684Optimal, 2.3330990, 252 itMaximum_Iterations_Exceeded, 15.484684
robot_cMaximum_CpuTime_Exceeded, 29.039906Optimal, 1.4059756, 109 itMaximum_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).

optiondefaultmeaning
theta_max_adaptive_trigger3consecutive fully gate-refused line searches before a raise; 0 disables
theta_max_adaptive_factor100geometric factor per raise
theta_max_adaptive_max_raises4cap 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 every kappa — are untouched by construction, not by a lucky constant.
  • A blocked model trips it immediately. robot_a is 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_max finite, 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:

modelrule off (trigger = 0)rule on (default)
robot_aMaximum_Iterations_Exceeded, 14.23Optimal, 1.0432009, 190 it
robot_bmax time, 15.484684Optimal, 2.3330990, 269 it
robot_cmax time, 29.039906Optimal, 1.4059756, 222 it
brainpc1Optimal, 64 itOptimal, 64 it — identical
brainpc3Optimal, 43 itOptimal, 43 it — identical
brainpc5Optimal, 982 itOptimal, 982 it — identical
brainpc7Optimal, 43 itOptimal, 43 it — identical
bt4Optimal, 9 it, −3.7047681836394486identical

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_pr is 0 or very small;
  • the solve stalls with inf_pr flat and alpha_pr tiny, and raising max_iter does 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:

kapparobot_abrainpc1brainpc3brainpc7
0 (default)max time, 616 itOptimal, 64 itOptimal, 43 itOptimal, 43 it
0.01Optimal, 287 itmax time, 3.8e8Acceptable, 149 itAcceptable, 552 it
0.05Optimal, 153 itmax timeAcceptable, 149 itAcceptable, 552 it
0.2Optimal, 127 itmax timeAcceptable, 149 itAcceptable, 552 it
1.0Optimal, 112 itmax timeAcceptable, 149 itAcceptable, 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 exceeds kappa · rows gets 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_max is 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-coding resto.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 to 1e8 · m, i.e. remove it, so the sub-IPM always runs with kappa = 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 of 1 against a bar of 1e-8. min −exp(x) s.t. x >= 0 running away to inf_du = 8.8e+47 is refused by eight orders, because its ∇f runs away by exactly the same factor.
  • The aggregate still has to pass. nlp_err <= tol is 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_tol until dual_scale exceeds dual_inf_tol / tol = 1e8, so every model with O(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.

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.

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.

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.

OptionDefaultMeaning
infeasibility_perturbed_start_retryyesRung 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_perturbation0.0Relative 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_seed0SplitMix64 seed for that displacement — no clock, no address, no thread identity, so the same seed gives the same point on every platform.
start_point_conditionernonenone, 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).

OptionDefaultMeaning
adam_warmup_iters200Iteration 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_rate5e-2Step 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_penalty10.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.

OptionDefaultMeaning
mu_strategymonotonemonotone (Fiacco–McCormick schedule) or adaptive (oracle-driven).
mu_oraclequality-functionAdaptive oracle: quality-function / loqo / probing.
mu_init0.1Seed value for μ at the first iterate.
mu_min1e-11Floor on μ; the solver stops decreasing past this. In both μ strategies the effective floor is capped at `compl_inf_tol·
mu_max1e5Cap on μ (adaptive mode). When set explicitly it overrides the mu_max_fact initialization.
mu_max_fact1e3Initializes mu_max as mu_max_fact · curr_avrg_compl at the first iterate (adaptive mode).
mu_target0.0Stop target for μ in monotone mode.
mu_linear_decrease_factor0.2κ_μ in μ ← min(κ_μ · μ, μ^θ_μ).
mu_superlinear_decrease_power1.5θ_μ in the same formula.
barrier_tol_factor10.0Inner-subproblem tolerance scales as barrier_tol_factor · μ.
tau_min0.99Floor 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_max1e2Upper clamp on σ chosen by the quality-function oracle.
sigma_min1e-6Lower clamp on σ (raising this to 1e-2 can break a stair-stepping stall on some problems).
adaptive_mu_globalizationobj-constr-filterAdaptive-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.

OptionDefaultMeaning
quality_function_norm_type2-norm-squaredNorm used to aggregate KKT components inside q(σ): 1-norm, 2-norm, 2-norm-squared, max-norm.
quality_function_centralitynoneCentrality penalty term: none, log, reciprocal, cubed-reciprocal.
quality_function_balancing_termnoneBalancing penalty when complementarity ≪ infeasibilities: none or cubic.
quality_function_max_section_steps8Cap on golden-section iterations when picking σ.
quality_function_section_sigma_tol1e-2Width tolerance in σ-space terminating the golden-section search.
quality_function_section_qf_tol0.0Relative 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.

OptionDefaultMeaning
adaptive_mu_safeguard_factor0.0LOQO safeguard floor on the oracle’s μ candidate.
adaptive_mu_monotone_init_factor0.8Multiplier on avrg_compl when seeding monotone mode after a bailout.
adaptive_mu_restore_previous_iteratenoRestore the latest free-mode iterate when switching to fixed mode.
adaptive_mu_kkterror_red_iters4Window length for the kkt-error globalization history.
adaptive_mu_kkterror_red_fact0.9999Required relative KKT-error reduction over that window.
adaptive_mu_kkt_norm_type2-norm-squaredNorm used to score the iterate in adaptive globalization decisions.
adaptive_mu_max_free_returns-1Cap on returns to free-μ mode after entering monotone mode; -1 is unlimited (upstream). POUNCE extension (#749).
adaptive_mu_budget_pin_fraction0.75Fraction 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.

valueneeds from the modelwhen
exact (default)eval_h — real second derivativesthe fast path whenever they exist
limited-memorynothing beyond the gradientno second derivatives available
finite-differencethe analytic Jacobian plus a sparsity patternno eval_h, but the Jacobian is exact and sparse
partitionedthe declared Jacobian sparsitystructured 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:

optiondefaultvalueswhat it does
fd_hessian_patterndeclareddeclared, jacobianwhere the sparsity pattern comes from
fd_hessian_coloringcprcpr, starhow columns are grouped into probes
fd_hessian_reuse_tol0≥ 0relative 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-difference will produce a Hessian for a model whose second derivatives do not exist, because it never asks for them. hessian_approximation=exact refuses 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.

optiondefaultvalueswhat it does
partitioned_elementsper-constraintper-constraint, blockshow the Lagrangian is split into elements
partitioned_update_typesr1sr1, bfgsupdate formula applied to each element block
partitioned_max_element64≥ 1widest element that keeps a dense block
partitioned_block_size64≥ 1target block width, elements=blocks only
partitioned_curvature_capoff (inf)> 0cap 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. 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_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.

OptionDefaultMeaning
limited_memory_initializationscalar1Formula 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_val1.0σ on the first iteration, before any curvature pair exists — and every iteration under constant.
limited_memory_init_val_min / _max1e-8 / 1e8Clamp 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):

casescalar1history-max
n = 4, cond 1e41229
n = 4, cond 1e81821
n = 4, cond 1e126128
n = 8, cond 1e4396166
n = 8, cond 1e82000*2000*
n = 8, m = 103466

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:

OptionDefaultMeaning
alpha_red_factor0.5Upper bound on one reduction: the trial sequence still contracts at least as fast as upstream’s.
alpha_red_factor_min0.05 under limited-memory, = alpha_red_factor (off) under an exact HessianLower 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.sheigena2 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_ComputationRestoration_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.

OptionDefaultMeaning
limited_memory_ls_failure_restarts0 (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:

OptionDefaultMeaning
l1_exact_penalty_barriernoRun the ℓ₁-exact penalty-barrier wrapper unconditionally.
l1_fallback_on_restoration_failurenoRetry with the wrapper only when the standard solve fails.
l1_penalty_init1.0Initial penalty weight ρ.
l1_penalty_max1e6Maximum penalty weight before declaring infeasibility.
l1_penalty_increase_factor8.0Multiplier applied to ρ each outer iteration.
l1_penalty_max_outer_iter8Maximum penalty outer iterations.
l1_slack_tol1e-6Fallback slack tolerance — see below.
l1_steering_factor10.0Steering-rule factor for ρ escalation.

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.

OptionDefaultMeaning
presolvenoMaster switch for the whole presolve layer. Off → wrapper is a no-op.
presolve_bound_tighteningyesPhase 1 — Andersen-style bound propagation from linear rows.
presolve_redundant_constraint_removalyesPhase 2 — drop linear constraints already implied by current bounds.
presolve_linear_eq_reductionnoPhase 6 — eliminate variables determined by linear equality rows (see below).
presolve_licq_checkyesPhase 3 — detect rank-deficient equality blocks before the IPM starts.
presolve_licq_actionwarnWhat to do on degeneracy: warn (just report) or auto_l1 (turn on ℓ₁).
presolve_warm_z_boundsyesPhase 4 — warm-start bound multipliers when bounds get tightened by Phase 1.
presolve_bound_mult_init_val1.0Value used by Phase 4 for those warm-start hints.
presolve_max_passes3Fixed-point iteration cap across the bound-tightening passes.
presolve_print_level0Per-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 = b pins its variable at b/a;
  • a two-variable row a₁·x + a₂·y = b substitutes one variable for the other, x := α·y + β. There is no anchoring requirement: a row linking two otherwise-free interior variables — an arc equality, a Reference alias, 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 = b whose value lands on one of its own bounds: the row multiplier absorbs it.

    A practical consequence for .sol readers, 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 no ipopt_zL_out / ipopt_zU_out entry 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.

OptionDefaultMeaning
presolve_fbbtnoMaster switch. Requires presolve=yes and an ExpressionProvider.
fbbt_tol1e-6Minimum per-variable bound improvement to keep iterating.
fbbt_max_iter10Outer-sweep cap.
fbbt_max_constraints0Per-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:

OptionDefaultMeaning
presolve_auxiliarynoMaster switch for the Phase-0 structural elimination pass.
presolve_auxiliary_couplingsafeWhich coupling classes are eligible: none / safe / aggressive.
presolve_auxiliary_tol1e-8Residual tolerance for accepting a candidate block solve.
presolve_auxiliary_max_block_dim8Largest block the lightweight Newton solver will attempt (larger blocks rejected in v1).
presolve_auxiliary_wall_time_fraction0.1Fraction of the solver’s wall-time budget the pass is allowed to spend.
presolve_auxiliary_diagnosticsnoEmit 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).

OptionDefaultMeaning
feral_orderingautoFill-reducing ordering method (see table below). auto lets feral’s adaptive dispatcher pick per-matrix; auto_race measures the actual symbolic outcome and keeps the best.
feral_pivtol1e-8Relative 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_refinenoWhether FERAL runs its own iterative refinement inside every back-solve. Off by default for the NLP solver (gh#710, reported as gh#698 observation 5), as on every direct linear solver Ipopt ships and on POUNCE’s own MA57. Note this is the option’s default; FeralConfig’s own 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 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_steps10Maximum 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 now the default — set feral_refine=yes before either knob has any effect.
feral_refine_target0Residual 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 now the default — set feral_refine=yes before either knob has any effect. Upstream fix: feral#190.
feral_increase_qualityyesWhether 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_retryyesRe-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.
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_fmanoDispatch 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_floor1e-20Pounce’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_floor1e-12Pivot 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. 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. 0 disables.
feral_min_par_flops1e8Flop 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.

ValueStrategy
autoDefault. Adaptive dispatcher: picks a concrete method per matrix from cheap pattern features. Branches: very-large-and-sparse (n > 100 000, avg degree < 5) → AMD; n ≤ 10 000 → AMF; otherwise → MetisND. One symbolic pass; right when the heuristic shape rules apply (the common case).
auto_raceRace-based dispatcher: runs full symbolic factorization on AMD, MetisND, ScotchND, KahipND and keeps the smallest factor_nnz. ~4× a single symbolic pass, paid once per problem (symbolic factorization is cached across numeric refactorizations with the same pattern). Use when the cheap dispatcher’s guess is suspect — e.g. pinene_3200_0009, where auto picks MetisND (88 s numeric factor) but amd factors in 19.5 s on the same matrix.
amdApproximate 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.
amfApproximate 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.
metisferal-metis multilevel nested dissection. Tends to produce squarer fronts than AMD on banded / nearly-1D structure; preferred for large structured matrices.
scotchferal-scotch nested dissection. Similar regime to METIS; alternative when METIS is unavailable or for cross-validation.
kahipferal-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.

When in doubt: leave feral_ordering at the default. When a hard problem looks linear-solver-bound, try feral_ordering auto_race before per-variant manual sweeping — it’s the safe choice when the per-problem winner is uncertain.

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) (and get_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.

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).

VariableOption
POUNCE_FERAL_ORDERINGferal_ordering
POUNCE_FERAL_SCALINGferal_scaling
POUNCE_FERAL_PIVTOLferal_pivtol (deprecated bare FERAL_PIVTOL also accepted)
POUNCE_FERAL_REFINEferal_refine
POUNCE_FERAL_REFINE_STEPSferal_refine_steps
POUNCE_FERAL_REFINE_TARGETferal_refine_target
POUNCE_FERAL_INCREASE_QUALITYferal_increase_quality
POUNCE_FERAL_CASCADE_BREAKferal_cascade_break
POUNCE_FERAL_FMAferal_fma
POUNCE_FERAL_SINGULAR_PIVOT_FLOORferal_singular_pivot_floor
POUNCE_FERAL_INERTIA_PIVOT_FLOORferal_inertia_pivot_floor
POUNCE_FERAL_MIN_PAR_FLOPSferal_min_par_flops
POUNCE_FERAL_STATIC_PIVOTINGferal_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.

VariableSubsystem (RUST_LOG target)Emits
POUNCE_DBG_AMUpounce::muAdaptive-μ per-iteration state (θ, f, oracle inputs).
POUNCE_DBG_ORACLEpounce::muμ-oracle probe-guard decisions (probe-Newton → restoration requests).
POUNCE_DBG_QFpounce::muQuality-function μ-oracle σ search (floor, current μ).
POUNCE_DBG_QF_AGGRpounce::muQuality-function aggregate step/complementarity terms per σ.
POUNCE_DBG_QF_SWEEP=<iter>pounce::muDumps the full quality-function σ sweep at the given iteration number.
POUNCE_DBG_DELTApounce::algorithmPrimal-dual search direction δ per iteration.
POUNCE_DBG_LS=1pounce::linesearchFilter line-search / backtracking acceptance trace (must equal 1).
POUNCE_DBG_PERTpounce::linsolInertia-perturbation handler decisions (WRONG_INERTIA, δ_w escalation).
POUNCE_DBG_PD_TAGSpounce::linsolPrimal-dual full-space solver dependent-block tag changes.
POUNCE_DBG_KKT_DUMP=<path>pounce::linsolWrites 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::linsolWrites the standard augmented-system KKT matrix to <path>. Deprecated — prefer --dump kkt:<iter-spec> (see pounce --help).
POUNCE_DBG_RESTOpounce::algorithm, pounce::restorationRestoration 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_CYCLEpounce::algorithmRestoration no-progress cycle-detector relative-step metrics.
POUNCE_DBG_RESTO_INITpounce::restorationRestoration initial-point vectors.
POUNCE_DBG_RESTO_KAPPApounce::restorationRestoration κ_resto convergence-guard evaluation.
POUNCE_DBG_RESTO_LOCINFpounce::restorationRestoration 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.

VariableValuesEffect
RUST_LOGe.g. info, debug, pounce::restoration=debugLog verbosity / per-target filtering. Default info. Logs go to stderr.
POUNCE_LOG_FORMATtext (default) · jsonjson emits line-delimited JSON on stderr (incl. the per-iteration pounce::iteration stream) for Studio / CI ingestion.
NO_COLORset to any valueDisables ANSI color in the iteration table and logs (see https://no-color.org).
CLICOLOR_FORCEset to any valueForces color even when stdout is not a terminal.

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