discopt.solver_tuning

Contents

discopt.solver_tuning#

Typed, per-call solver tuning — the Pythonic replacement for DISCOPT_* flags.

The spatial-B&B relaxation has a number of advanced tuning levers (RLT families, McCormick separation toggles, node-bound mode, …) that were historically read straight from DISCOPT_* environment variables at scattered points inside the relaxer. That made them global process state: not per-Model, not per-solve, not thread-safe, invisible to help(model.solve), and unvalidated.

SolverTuning collects them into one validated, typed object. Each field still defaults to its DISCOPT_* env var (read at instantiation, not at import — so it is never frozen and an explicit field always wins), so existing env-var workflows keep working as deprecated defaults while the object is the supported, discoverable surface:

from discopt import SolverTuning
model.solve(tuning=SolverTuning(rlt_quad=False, node_bound_mode="nlp"))

Internally solve_model resolves the object once and publishes it on a ContextVar; the relaxer read sites call current() instead of touching os.environ. Outside a solve, current() falls back to a fresh env-resolved instance, so direct relaxer use (e.g. in tests) is unaffected.

Attributes#

Classes#

SolverTuning

Advanced relaxation / branch-and-bound tuning for Model.solve().

Functions#

current(→ SolverTuning)

The active SolverTuning (a fresh env-resolved one outside a solve).

set_current(tuning)

Publish tuning (or a fresh env-resolved one) as active; returns the token.

enter_scope(tuning)

Publish tuning for a solve scope, inheriting the active context when

reset_current(→ None)

saturate_role2(→ float)

Cap a role-2 sub-budget carved as frac of time_limit (#1153).

heuristic_entry_share(→ float)

The budget share a finder-role heuristic must fit in (#1153).

Module Contents#

class discopt.solver_tuning.SolverTuning#

Advanced relaxation / branch-and-bound tuning for Model.solve().

Every field defaults to its legacy DISCOPT_* environment variable (resolved when the instance is created), so a bare SolverTuning() exactly reproduces the env-driven behavior, and any explicitly-set field overrides it. All fields are validated on construction.

node_numerical_dual_bound: bool = True#

Attach a Neumaier–Shcherbina safe lower bound from the in-house simplex’s own dual candidate when the node LP solve breaks down numerically (DISCOPT_NODE_NUMERICAL_DUAL_BOUND, default ON since the #362 graduation — DISCOPT_NODE_NUMERICAL_DUAL_BOUND=0 restores the legacy no-rescue behavior). Fires only where the certified in-house chain (warm → equilibrated) produced no bound: the hda-class flowsheets whose ill-conditioned LPs defeat phase-2 (#517, see docs/dev/hda-no-bound-simplex-robustness-2026-07-16.md) and the nvs05 certification-edge decline class, where the stashed NS bound is also surfaced as safe_bound on an optimal generic-path solve so _certify can certify the node instead of declining it into a non-rigorous sentinel fathom (#362, see docs/dev/nvs05-decline-taint-2026-07-16.md). The NS bound is valid for ANY dual vector, so a drifted-basis dual only loosens it, never lifts it above the optimum; never fathoms on its own; a finite NS value proves the LP is bounded, so it can never fabricate a bound on a genuinely unbounded relaxation. No external solver (the removed #517 HiGHS rescue is NOT resurrected). Graduation evidence (2026-07-16, in-container): 65-instance panel ON-vs-OFF — 0 proofs lost, no bound loosened (one beuster wall-jitter artifact, byte-identical in isolation); differential gate GREEN (at-least-as-tight per box, feasible-point 0 cuts, worst 1.8e-11); graduation_gate cert-neutrality eligible=YES; nvs05 gains its first full rigorous certificate (optimal, bound 5.47057).

structured_node_recovery: bool = True#

Solve _pounce_recover_node_bound’s re-solve with POUNCE’s structured convex engine (pounce.solve_qp) instead of the generic callback TNLP path (qp_pounce.solve_qp → pounce.Problem(problem_obj=_QPCallbacks)) (DISCOPT_STRUCTURED_NODE_RECOVERY, default ON since the §5 panel below; =0 keeps the callback path, which is retained intact).

This is the same migration _solve_node_lp_pounce already carries, and for the same measured reason: the callback path hides the linear structure, so POUNCE’s presolve cannot engage and its IPM runs ~100 iterations where the structured path presolves/scales and converges in ~20. Node-bound recovery was left behind when the MILP node path was moved over, which is why a node QP costs ~8 ms while recovering the same problem costs 8-33 s.

Measured (#1064 entry experiment 4, same fixings, both arms at each node, 4 comparisons/instance): squfl015-060 21.8x, squfl025-040 19.4x, squfl020-150 5.9x. Verdicts agreed 4/4 on the first two, and objectives where both returned optimal agreed to 2.8e-7 / 4.8e-7 relative. On squfl020-150 the callback arm returned no answer at all (limit, ~32 s) on all four, where the structured arm settled every one in ~5.5 s (3 Phase-1-certified infeasible, 1 optimal) — so the disagreement is the legacy path failing to answer, not a soundness split.

Soundness is unchanged: an optimal result from the structured convex IPM is KKT-valid (the property _solve_node_lp_pounce already relies on) and primal_infeasible is Phase-1-certified; anything else stays None so the caller keeps the node open and never prunes on an unsettled solve.

Graduation evidence (2026-08-19, §5 regime 2, 69 instances x 60 s, in-repo corpus plus the three squfl instances the corpus lacks). This flag does NOT graduate on its own: alone it is cert-clean (unsound=0, cert_regressions=0) but not net-positive — 0 incumbents gained, bound tighter on 1 and looser on 5 — which is the DISCOPT_CUT_INHERIT case exactly. It graduates as half of a pair, because it is what lets round_fix_resolve’s re-solve answer at all on the largest instance: with this flag OFF, all 3 rungs of the rounding ladder return None after 14-18 s each on squfl020-150; with it ON, rung 1 certifies infeasible in 5.5 s and rung 2 returns optimal in 0.13 s. The paired arm (ROUND_FIX_RESOLVE + this) scores unsound=0, cert_regressions=0, 3 incumbents gained / 0 lost, bound tighter 3 / looser 2, nodes fewer 9 / more 3, total wall 1196.7 s vs 1210.6 s OFF — cert-clean AND net-positive, and better than ROUND_FIX_RESOLVE alone on every axis, so the pair is the shipping configuration.

round_fix_resolve: bool = True#

Round a fractional MIQP node relaxation point to the nearest integers, fix them, and re-solve for the continuous completion, so a search that never lands near-integral can still obtain a first incumbent (DISCOPT_ROUND_FIX_RESOLVE, default ON since the §5 panel recorded under structured_node_recovery above; =0 restores the old snap-only behaviour).

The existing purification (_pounce_snap_incumbent) only accepts points already integral to within _SNAP_TOL = 1e-4. On squfl020-150 and squfl025-040 (#1064) that gate never opens: both run a full 120 s budget with zero snap re-solves and finish with no incumbent, hence no primal bound and nothing to prune against. Rounding is the missing step, not a faster engine.

Spent only while tree.incumbent() is None and capped at 64 attempts per solve, so a family where every rounding is infeasible cannot consume the search. A rounded point can be genuinely infeasible — measured on squfl025-040, 7 forced fixings split 2 optimal / 3 Phase-1 infeasible / 2 unsettled — so a non-optimal verdict is retried once with the most fractional coordinate rounded the other way.

This can only ever produce an upper bound: it never prunes, never tightens a node bound, and never decertifies, and the completed point is run through the same _node_point_feasible gate as a snapped one before injection. A wrong guess costs time, not correctness.

lp_iterative_refinement: bool = False#

When a node LP breaks down numerically (the hda-class ill-conditioned McCormick relaxations whose near-singular bases the float64 simplex cannot certify), recover a tight dual bound by re-solving a few RHS-regularized neighbours [A|I] z = b + tau in the in-house simplex and keeping the tightest Neumaier–Shcherbina safe bound the recovered duals imply (DISCOPT_LP_ITERATIVE_REFINEMENT, default OFF — issue #671). A small tau perturbs the RHS just enough for the simplex to certify the well-conditioned neighbour and hand back a good multiplier; the NS bound is then evaluated against the original b (never b+tau), so it is a valid lower bound for any recovered dual — clamping/regularization can only tighten it, never lift it above the optimum. The reported bound is the max over the sweep and candidate A’s drifted-dual bound (#662), so it is never looser than the candidate-A floor it supersedes and never unsound. Fires only on the numerical-failure path (root / failure-triggered), never the hot per-node engine, and uses no external solver (the #517 HiGHS rescue is not resurrected). On hda this moves the root dual bound from candidate A’s −1.80e10 to ≈ −6.47e4 (the true root McCormick value). See docs/dev/issue-671-gsw-iterative-refinement-2026-07-18.md and the crates/discopt-core/src/lp/simplex/refine.rs kernel.

relax_row_filter: bool = True#

Failure-triggered, default ON (#671 graduated 2026-07-18; opt out with DISCOPT_RELAX_ROW_FILTER=0). When a node LP breaks down without a certified verdict (numerical, or a spurious infeasible with no Farkas proof — the hda-class ill-conditioned relaxations), drop the rows whose coefficients float64 cannot resolve at the feasibility tolerance (nonzero coefficient magnitudes spanning > 1e6 orders, or outside [1e-8, 1e8]) and re-solve once (DISCOPT_RELAX_ROW_FILTER, default ON — issue #671). Fires in mccormick_lp._solve_at_node_impl after the primary solve; not at build time. Sound by construction: removing relaxation rows yields a superset feasible region — a valid (weaker) outer approximation; the bound can only loosen, never falsify.

Failure-triggered (not always-on) because the in-repo differential panel showed an always-on build-time filter drops rows carrying genuine tightness on already-solving instances — 10/66 regressions, including nvs09 losing its `optimal` certificate (see the panel in discopt_benchmarks/results/issue671/rowfilter_diff_panel.py). Firing only on a failed solve makes the flag byte-identical on every already-solving node (the un-filtered solve is optimal/Farkas-infeasible there), while still recovering hda: its root LP false-fails, the filter drops the 130 float64-intractable rows (raw spread 2.837e26, κ≈1e14; measured to carry ZERO root tightness), and the in-house simplex then solves cleanly with the tight NS-certified bound — no τ-sweep, no factorization hardening, no external solver.

Graduated default-ON (§5, 2026-07-18): the graduation panel over the 66-instance in-repo corpus + hda is cert-clean — every already-solving instance is byte-identical (the filter is inert there: rows_dropped == 0, proven directly rather than via the noisy, non-deterministic node_count proxy) — and net-positive (hda −1.80e10 → −64473.44, sound ≤ opt). Sound SOFT losses (looser partial bounds when the filter fires on both-arms- timeout ill-conditioned instances, e.g. bchoco07/08) are acceptable per the §5 net-positive rule; a scoped interval-fallback max-combine on filtered nodes is the recorded follow-up tightening. Opt out with DISCOPT_RELAX_ROW_FILTER=0 (the legacy no-filter path is intact). See docs/dev/hda-certification-rowfilter-entry-2026-07-18.md and docs/dev/issue-671-resolution-plan-2026-07-18.md.

relax_row_filter_loose_bound: bool = True#

Also run the #671 row-filtered re-solve when a node LP is optimal but its certified bound sits far below the vertex objective, and keep the tighter of the two certified bounds (DISCOPT_RELAX_ROW_FILTER_LOOSE_BOUND, default ON; =0 restores failure-only triggering). Requires relax_row_filter.

#1229: the dual_slack_basis free-column fix lets the warm simplex converge on hda’s root LP instead of hitting its iteration limit, so the failure trigger above no longer fires. The vertex is primal feasible at −64675.25, but the Neumaier–Shcherbina bound read off its duals is −5.71e6 (a 200-node solve: −64509.8 → −1.40e10). The filtered LP certifies −64675.2492, the value main recorded. Sound: dropping rows gives a superset LP, so its certified bound is a valid lower bound, and max of two valid bounds is valid. Inert unless the node’s matrix has a float64-intractable row, so already-solving nodes see one extra if. See docs/dev/lp-milp-highs-routing-plan.md §12.

ns_sharp_margin: bool = True#

Replace the flat 1e-9-relative Neumaier–Shcherbina evaluation margin with a rigorous forward-error bound computed from the actual data (Higham dot-product gammas + interval corners on sign-uncertain reduced costs) (DISCOPT_NS_SHARP_MARGIN, default ON; =0 restores the flat margin). Graduated default-ON 2026-07-16 on the owner’s direction after the 66-instance differential panel passed (incorrect_count=0, no certification regression; docs/dev/integer-ratio-partition-2026-07-16.md §5b). On magnitude ~1e5 decompositions (gear4 piece LPs) the flat margin costs 2.9e-4 of every certified LP bound; the sharp margin costs ~1e-6. The sharp path also abstains when a sign-uncertain reduced cost sits next to an unbounded box side (the legacy path silently contributes 0 there — a latent soundness gap the sharp path closes), so it can return None where the legacy path returned a value — never the reverse.

rlt: bool = False#

Legacy whole-relaxation RLT toggle (DISCOPT_RLT). The rlt= argument to Model.solve() is the primary control; this OR-s in alongside it.

rlt_quad: bool = True#

Quadratic RLT row generation (DISCOPT_RLT_QUAD, default on).

rlt_sparse_auto: bool = False#

Structure-aware widening of the RLT auto-engage gate for sparse-bilinear models (DISCOPT_RLT_SPARSE_AUTO, default off; issue #727).

The default RLT auto policy gates build-time level-1 RLT and the per-node RLT cut family on a raw variable count (_AUTO_RLT_LEVEL1_MAX_VARS / _AUTO_CUTS_MAX_VARS in solver.py). That is a poor cost proxy: RLT’s cost is driven by the number of lifted product columns / rows, not the variable count. A pooling / bilinear-flow network has a sparse bilinear structure — the number of product terms grows ~linearly with the variable count — so its RLT relaxation stays small and solvable well past the raw-count cap, while a dense QCQP grows its products quadratically and is correctly excluded.

When on, the auto gate additionally admits a model whose product-term count is within rlt_sparse_max_terms AND whose variable count is within rlt_sparse_max_vars — the sparse-bilinear envelope. RLT is always sound (a constraint×bound-factor product is non-negative at every feasible point), so this only ever trades relaxation size for bound tightness, never correctness. Bound-changing → default-off pending the corpus-wide differential graduation panel (see docs/dev/performance-plan.md).

rlt_sparse_max_vars: int = 200#

Variable-count ceiling for the sparse-bilinear RLT widening (DISCOPT_RLT_SPARSE_MAX_VARS, default 200). Bounds the per-node re-solve cost of the enlarged relaxation when a model does not close at the root. Only consulted when rlt_sparse_auto is on.

rlt_sparse_max_terms: int = 300#

Product-term (lifted-column) budget for the sparse-bilinear RLT widening (DISCOPT_RLT_SPARSE_MAX_TERMS, default 300). Counts bilinear + trilinear + multilinear product terms; caps the RLT relaxation size directly, so a dense QCQP (products ~ n^2) is excluded while a sparse pooling network (products ~ n) is admitted. Only consulted when rlt_sparse_auto is on.

rlt_sparse_root_probe: bool = True#

Root-productivity gate on the sparse-bilinear RLT widening (DISCOPT_RLT_SPARSE_ROOT_PROBE, default on when rlt_sparse_auto is on; issue #727).

Structure alone is necessary but not sufficient: a sparse-bilinear model is only worth the enlarged per-node relaxation if RLT actually tightens the root bound. RLT helps precisely when it closes / near-closes the root (a pooling network — RLT is paid once and the tree collapses); it is net-negative when the root stays open (a heat-exchanger network — the heavier node LP just starves branching and the incumbent search, with no bound gain). Measured root gain |b_rlt − b_noRLT| / (|b_noRLT| + 1): pooling ≈ 0.68, heatexch ≈ 1e-11 — ten orders of magnitude apart.

When on, the widening additionally requires a bounded root probe to show a relative root-bound improvement ≥ rlt_sparse_min_root_gain. The probe solves the root McCormick LP with and without RLT once at setup (cheap: the size ceiling keeps it small); on any probe failure the widening is declined (never regress the default). Set to off to recover the structure-only gate (for A/B).

rlt_sparse_min_root_gain: float = 0.01#

Minimum relative root-bound improvement for the productivity gate to engage the sparse RLT widening (DISCOPT_RLT_SPARSE_MIN_ROOT_GAIN, default 1e-2 = 1%). The RLT-inert vs RLT-productive populations are ~10 orders of magnitude apart (heatexch 1e-11 vs pooling 0.68), so the exact value is not sensitive; 1% requires a real, non-noise tightening. Only consulted when rlt_sparse_root_probe is on.

rlt_quad_max: int = 256#

Column cap for quadratic RLT (DISCOPT_RLT_QUAD_MAX, default 256).

rlt_lineq: bool = True#

Build-time RLT products of linear equality constraints with the original variables (DISCOPT_RLT_LINEQ, default on since #1944’s graduation panel; =0 opts out).

For a linear equality a'x + c == 0 and a variable x_j, the product (a'x + c) * x_j == 0 linearizes over the lifted product columns to sum_i a_i X_ij + c x_j == 0 — the Sherali–Adams level-1 constraint-factor row. It holds with equality at every feasible point, so it never cuts one.

Unlike the bound-factor RLT families (rlt/rlt_quad), this row contains no box data: it is identical at every node, which is why it can be emitted once as a fixed row and carried into the native spatial kernel unchanged.

Motivation (measured, QPLIB continuous nonconvex QPs, root LP bound vs the published optimum): the McCormick-only root bound is hopeless on this class, and all of the recoverable gap on the instances that respond comes from this equality family — the bound-factor rows added nothing on top of it:

instance

McCormick

+linear-eq RLT

optimum

gap closed

QPLIB_1157

-14.8046

-11.7716

-10.9482

78.6 %

QPLIB_1493

-85.6492

-65.5915

-43.1604

47.2 %

QPLIB_1143

-140.3576

-114.7060

-57.2467

30.9 %

QPLIB_1423

-25.6777

-22.6093

-14.9675

28.6 %

QPLIB_1507

-18.1057

-15.8070

-8.3014

23.4 %

No bound crossed its published optimum on any instance in the probe.

On the native spatial kernel (#1944). Until #1944 a model carrying these rows never reached the kernel. A row like X_{36,j} == 0 (from x36 == 0) has its support inside one product’s operands, and the producer counted it as a fifth McCormick row and declined. The rows are now tagged (UniformRelaxation.nonenvelope_rows) and carried as fixed rows. Two measurements decide how to use them there:

  • Use ``rlt_lineq_max = 0`` on the kernel. Existing products give the same bound as lifting new ones. nous1’s root LP is 0.0795 either way, but lifting grows the relaxation from 106 to 1,074 columns, and the kernel then manages 55 nodes in 120 s instead of 16,231.

  • The rows need a node LP that does not start cold. The kernel’s cold primal ends Numerical / IterLimit on these equality-rich relaxations, and an undecided node is bisected with no bound gain. On pooling_adhya2tp (30 s) undecided nodes go 6 -> 2,618 with this flag, and the instance loses its certificate. With DISCOPT_NATIVE_WARM_START=2 or DISCOPT_LP_COLD_DUAL_START=1 it is 0 and the instance certifies (433 nodes vs 387). The warm start graduated (mode 2 is the default, #1949).

Graduated (#1944) together with rlt_ineq, with rlt_lineq_max = 0. distribute_affine_products was in the first bundle panel but stays opt-in (see its docstring). Panel: issue1944_rlt_panel.py --with-corpus on the warm-start main, ON = lineq + ineq, OFF = both =0. Population: 270 instances (the in-repo corpus plus every nonconvex polynomial MINLPLib instance with at most 60 vars/cons and a proven optimum), 30 s, interleaved, every incumbent verify_point-ed.

  • Cert-clean: 1,620 checks, 0 problems.

  • No certificate gained or lost.

  • Where both certify: nodes 66,530 -> 63,066, wall 558.8 -> 518.3 s.

  • hda’s 30 s bound -64,510 -> -7,002 (optimum -5,964); nous1 0.41 -> 0.89.

Retracted (#1983): this list also said “the Haverly pooling models close at the root”, crediting these rows. They do not. On the in-repo Haverly models, every combination of DISCOPT_RLT_LINEQ/DISCOPT_RLT_INEQ (both on, both off) and DISCOPT_KERNEL_CUTOFF_OBBT gives the same result (examples.example_pooling_haverly: optimal at node 1; MINLPLib haverly: optimal in 57 nodes, root bound -600). On the optimization book’s Haverly instances (#1983) it is #1871’s cutoff OBBT that closes them at the root: without it they take 3, 11 and 13 nodes, and turning both RLT flags off changes nothing.

performance-plan.md section 83 has the record.

rlt_lineq_max: int = 0#

New-column cap for linear-equality RLT (DISCOPT_RLT_LINEQ_MAX, default 0: existing products only, the policy of SCIP’s RLT separator). Products already registered by the base decomposition are free; only columns this pass has to lift itself count against the cap. The default was 4096 while the flag was off. #1944 measured lifting on the kernel route: the same root bound on nous1 (0.0795), but 1,074 columns instead of 106 and 55 nodes in 120 s instead of 16,231. A positive value restores lifting, which the QPLIB root-LP measurement above used.

rlt_ineq: bool = True#

Bound-factor RLT rows for the linear inequalities in the native spatial kernel’s relaxation, over products the build already lifted (DISCOPT_RLT_INEQ, default on since #1944’s graduation panel, see rlt_lineq; =0 opts out).

For g + a'x >= 0 and a variable x_j in [l, u] whose product with every variable of the row is already a lifted column, adds the linearized (g + a'x)(x_j - l) >= 0 and (g + a'x)(u - x_j) >= 0 (_emit_linear_inequality_rlt). No column is added. The rows carry the kernel’s ROOT box, which keeps them valid at every node. They are not rebuilt per node.

Measured on tln (SCIP 10 at limits/nodes = 1): every RLT cut SCIP applies at tln4’s root is in this family, and removing its RLT separator takes the root from 8.3 to 3.8. Only the kernel producer passes it; the Python tree’s relaxation is unchanged.

Measured on tln’s unlifted kernel route (the integer-bilinear lift declined, 120 s). Roots go from 3.02 / 4.03 / 5.37 to 7.66 / 9.66 / 14.69 on tln4/5/6. The certificates need DISCOPT_NATIVE_WARM_START=2 too. With it, tln4/5/6 certify in 0.9 / 2.2 / 36.3 s; the warm start alone gives 1.3 / 20.1 s and leaves tln6 open. Without the warm start, #1943’s strong branching solves child LPs cold, and the cold primal leaves 4,943 of tln5’s 11,869 nodes undecided, so the flag on its own is slower. tln reaches this route by default only if the lift is declined (DISCOPT_IPX_CHEAP_FIRST).

distribute_affine_products: bool = False#

Distribute a product of two affine forms over disjoint original variables into a sum of bilinear products before relaxing it (DISCOPT_DISTRIBUTE_AFFINE_PRODUCTS, default off; =1 opts in; #1944).

Why it is not the default. It breaks #671’s row-filter recovery on hda’s raw-box root LP. MccormickLPRelaxer.solve_at_node at the declared box, lineq rows on: filter on/off certifies -7,063 / -7.0e8 without distribution and -5.6e8 / -5.4e6 with it. That is test_hda_root_bound_is_not_the_loose_safe_bound. The real solve’s root is unaffected (-7002.39 both ways after presolve), and the bundle panel with distribution in was cert-clean and net-positive. But the interaction is not understood, so the flag stays opt-in. What would change that: a root cause for the hda interaction and a fix, then the bundle panel re-run.

Without it (c + a'x) * (d + b'y) is one product w = A*B whose McCormick envelope uses the interval of each whole form. Distributed, it becomes c d + c b'y + d a'x + sum_ij a_i b_j x_i y_j, one McCormick envelope per pair.

Never looser. Each pair envelope is the McCormick envelope of x_i y_j, and McCormick is invariant under scaling a factor (an endpoint swap for a negative coefficient), so it is also the envelope of u_i v_j with u_i = a_i x_i, v_j = b_j y_j. Summing u_i v_j >= uL_i v_j + vL_j u_i - uL_i vL_j over i, j gives exactly A B >= AL B + BL A - AL BL with AL = sum uL_i. The same holds for the other three inequalities, so every aggregated row is implied by the distributed ones. Disjoint supports are required. A variable in both forms would turn its pair into a square, and that argument does not cover the square’s own envelope.

Usually strictly tighter, and much tighter when pairs are shared. The distributed pairs are ordinary bilinear nodes, so hash-consing gives them the same lifted column as the same product written elsewhere in the model. On nous1 the objective’s (c + a'x) * x0 shares x2*x0 and x3*x0 with the constraints x24 = x2*x0 and x25 = x3*x0. Root LP at the presolved box (#1944): nous1 -0.847 -> -0.636, nous2 -0.830 -> -0.435.

Only where a pair is shared. A product is distributed only if at least one of its pairs is already a product elsewhere in the model. Sharing is where the gain comes from; without it every pair is a new column. #1944 measured tanksize: 38 new pairs, none shared. Its root moved 0.8382 -> 0.8419, but the kernel tree doubled (19,039 -> 37,709 nodes) and the instance lost its certificate in the panel. Over the panel population, every product distributed on an instance that gained had a shared pair.

The pair count is capped by distribute_affine_products_max. A product above the cap is left as one affine-form product, which is the flag-off behaviour.

distribute_affine_products_max: int = 64#

Most bilinear pairs one distributed product may produce (DISCOPT_DISTRIBUTE_AFFINE_PRODUCTS_MAX, default 64). Each pair can be a new lifted column with four McCormick rows.

multilinear_rlt_max: int = 4#

Max arity for multilinear RLT lifting (DISCOPT_MULTILINEAR_RLT_MAX).

multilinear_separate: bool = True#

Separate multilinear McCormick cuts (DISCOPT_MULTILINEAR_SEPARATE).

trilinear_nested: bool#

Force the legacy nested-bilinear trilinear path (DISCOPT_TRILINEAR=nested; equivalent to the default unless another trilinear selector is explicitly set).

trilinear_meyer: bool#

Use the Meyer-Floudas/Rikun trilinear convex-hull envelope (DISCOPT_TRILINEAR=meyer, default off).

trilinear_exact: bool#

Use the best-of-three nested trilinear envelope (DISCOPT_TRILINEAR=exact, default off).

trilinear_rlt: bool = True#

Trilinear RLT rows (DISCOPT_TRILINEAR_RLT, default on).

integer_multilinear_reform: bool = False#

Flow-aware exact linearization of integer-multilinear products — products of >=3 variable factors where every factor but at most one is integer- or binary-valued (declared or implied), e.g. (c + k*x_cont)*x_i*x_j*x_ind with x_i,x_j integer flow factors and x_ind a 0/1 indicator (DISCOPT_INTEGER_MULTILINEAR_REFORM, default off; issue #707).

Each integer factor is binary-expanded (x = lo + sum 2^k e_k) and the resulting product of binaries is lifted to its exact hull — an n-ary AND (z <= e_i, z >= sum e_i - (n-1), z binary) for the pure-integer monomials, plus one big-M product (v = e*x_cont) for the single continuous factor. The rewrite is a value-preserving algebraic identity; only the relaxation changes (the loose term-wise trilinear McCormick envelope over the continuous box is replaced by the per-integer-level exact envelope), so it is sound and can only tighten the dual bound. Unlike the pure-bilinear integer reform (which is adopted only when it yields a pure MILP), this pass is retained on the spatial branch-and-bound path when residual continuous nonlinearity remains — the tightening of the integer-multilinear terms is a strict gain there (ex1252: lifts the SOS1-selector-branch dual bound off its 5134 floor).

Bound-changing (CLAUDE.md §5): default-off behind this flag until a corpus-wide differential panel graduates it.

square_separate: bool = True#

Separate tightened square (x**2) cuts (DISCOPT_SQUARE_SEPARATE).

edge_concave: bool = True#

Edge-concave aggregation cuts (DISCOPT_EDGE_CONCAVE, default on).

psd_cost_gate: bool = True#

Adaptive cost-aware gate on the per-node PSD (moment) cut separation loop (DISCOPT_PSD_COST_GATE, default ON since G1.3; DISCOPT_PSD_COST_GATE=0 is the escape hatch). PSD separation dominates the QCQP root wall (~60% on nvs17/19/24 per THRU-1) while the certified bound is set by McCormick+RLT and reached by branching when PSD is absent, so unbudgeted PSD starves the tree search. When on, this bounds the wall each node’s PSD loop may spend to psd_cost_gate_budget × that node’s own base LP-solve wall, and abandons the loop early once a round’s relative LP-bound improvement falls below psd_cost_gate_tau (diminishing returns). It gates ONLY the PSD (moment-cut) loop — the univariate-square separator was measured to over-reach onto non-QCQP instances (tspn05 optimal→feasible), so it is left untouched. Keys purely on observed per-node cost/bound-delta — never on instance name/shape (§0.2). SOUND by construction: dropping valid cuts can only loosen the relaxation, never cut a feasible point or cross the optimum.

Graduated to default-ON (G1.3-redo, post-C-38) on gate evidence: the isolated held-out arm (N=40, seed 0, tl 25 s) verdicts eligible — 0 soundness violations, cert-neutral (bound-changing regime), regression 0 % — plus the bound-changing verification (differential dual bound a valid underestimator that never crosses =opt= on nvs17/ex5_3_3; feasible-point sample recovers the identical incumbent ON vs OFF). See docs/dev/flag-graduation-redo-2026-07-07.md.

psd_cost_gate_budget: float = 1.0#

PSD wall budget per node as a multiple of that node’s base LP-solve wall (DISCOPT_PSD_COST_GATE_BUDGET, default 1.0). The PSD loop stops once its cumulative wall this node exceeds budget × base_solve_wall. Only consulted when psd_cost_gate is on.

psd_cost_gate_tau: float = 0.0001#

Relative diminishing-returns threshold for the PSD loop (DISCOPT_PSD_COST_GATE_TAU, default 1e-4). A round whose LP-bound improvement Δ ≤ tau × (1 + |lb_before|) abandons the remaining PSD rounds at that node. Only consulted when psd_cost_gate is on.

cut_inherit: bool | None = False#

Root-cut-pool inheritance for the per-node square/PSD separation loops — tri-state, opt-in (DISCOPT_CUT_INHERIT: unset / 0 ⇒ force-off = the shipped default; gated/auto ⇒ structure-gated opt-in; 1 ⇒ force-on. Programmatically cut_inherit=None selects the structure gate.)

THRU-3 measured that the two per-node point separators — the univariate-square tangent loop and the PSD (moment) loop — are the dominant per-node cost on the cut-firing quadratic class (nvs24: 73% + 12% of the solve wall), each re-deriving cuts via up to 8 full MILP re-solves at EVERY node. When active, the root separates the full cut chain ONCE (unchanged root behaviour), the accepted rows are stored in the root cut pool, and every node inherits the pool instead of re-running the square/PSD separation loops.

Structure gate (CUT-INHERIT-GRAD). The activating predicate is whether a non-empty root cut pool is separated at the root — a cheap, general, root-time signal that keys on measured structure, never on instance name/shape (CLAUDE.md §2). When the model carries the square/PSD-liftable structure the pool populates and inheritance engages (measured broadly beneficial: nvs17/19/23/24 1.6–5.4×, kall_circles 1.8–2.6×, knp3-12 ~4–9×, dispatch 3.3×; nvs19 gains its certificate); when it does not, the pool is empty, nothing is inherited or skipped, and the solve is byte-identical to the force-off path (node_count + objective unchanged).

Why still opt-in (default force-off). The CUT-INHERIT-GRAD entry experiment falsified THRU-4-graduate’s “the 2–5× is specific to the dense integer-QP class, broad flip is throughput-neutral” (that 1.004× was a TL=30s / parallel-contention artifact; under clean serial measurement every pool-firing instance benefits, so the honest gate is pool-fires ⇒ ON). BUT the same validation surfaced a flag-path false-optimal on the pure-integer / MINLP cold-path class: nvs22 certifies 33.55 against the oracle optimum 6.0582 — an nvs06-class incumbent-search reroute that C-42 (#553) only partially fixed (its pool-drop-retry does not trigger when the pool solve succeeds but the pre-tree pump is rerouted). Per CLAUDE.md §1 a false certificate blocks any default-ON flip, so the flag stays OPT-IN until that bug is fixed. See docs/dev/cut-inherit-grad-2026-07-08.md.

SOUND (where it fires cleanly): the inherited square tangents (s ≥ 2·x0·x − x0²) and PSD eigencuts (vᵀMv ≥ 0) are valid at every feasible lifted point independent of the node box, and every other captured family is valid over the ROOT box, hence over every descendant sub-box; skipping per-node re-separation only loosens the node relaxation (never cuts a feasible point). See docs/dev/thru4-cut-inheritance-2026-07-07.md for the per-family validity classification and measurements.

sparse_large_lp: bool = False#

Solve the per-node McCormick LP even when its lift exceeds the _MAX_RELAX_DENSE_CELLS dense-cell guard (DISCOPT_SPARSE_LARGE_LP, default off). The whole per-node path is now sparse — relaxation build (CSR), incremental patch (T11), simplex (CSC), exact-LP oracle (T8) — so the guard’s “would force a multi-GB dense allocation” premise is obsolete: a huge lift (e.g. qap’s 85756-row McCormick relaxation) solves in ~0.1 s at <1 GB. On this flag the guard becomes nonzero-based (_MAX_INCREMENTAL_NNZ) instead of dense-cell-based, so a large sparse lift earns its rigorous McCormick LP bound instead of being declined (no per-node relaxation at all when n_vars > 50, where alpha-BB is ineligible). Sound: the LP bound is a valid lower bound and the B&B keeps the parent bound as a floor, so enabling it never loosens a node — only adds a bound. Default off pending a benchmark instance that measurably benefits (qap’s indefinite-QP McCormick bound is ~0; see docs/dev/sparse-milp-plan.md T7/T12).

root_lp_probe_tight: bool = True#

Probe the spatial-path McCormick LP relaxer over the FBBT/OBBT-tightened root box rather than the raw declared model bounds when deciding whether to keep it for the whole search (DISCOPT_ROOT_LP_PROBE_TIGHT, GRADUATED default-ON #282 Workstream A; =0 restores the legacy raw-box probe).

The keep/discard “probe” solves the McCormick LP once at the root. Run over the raw declared bounds, a model whose continuous variables are declared [0, inf] (the process-synthesis *hfsg family, issue #282; also casctanks) makes that LP unbounded/None, so the rigorous relaxer is wrongly discarded (_mc_mode = "none") and the whole spatial search falls back to a far looser alphaBB/interval/NLP root bound — even though the SAME relaxer yields a valid, much tighter bound on the tightened box the solver has already computed (measured: syn30hfsg root excess +955%→+571%, syn40hfsg +3041%→+2350%, syn15m02hfsg +124.7%→+118.1%). With the tightened box the relaxer is kept and every node gets the LP bound.

SOUND by construction (CLAUDE.md §5): the probe only decides whether to keep the (rigorous outer-approximation) relaxer; every node still solves its own sub-box and the relaxer bound joins via max, so it can only tighten a node bound — never cut a feasible point or cross the optimum. Bound-neutral (byte-identical) on every model whose _mc_mode the probe does not change (the convex nlp_bb half, and all bounded-box spatial models).

GRADUATED default-ON on the CLAUDE.md §5 Regime-2 panel (discopt_benchmarks/scripts/issue282_root_lp_probe_graduation_panel.py; verdict JSON under discopt_benchmarks/results/issue282/): flag ON vs OFF over the vendored 66-instance corpus + the 7-instance #282 panel. cert-clean — 0 flag-induced soundness regressions across 73 instances (no dual bound past =opt=, no gap_certified True→False, certified optima identical, incumbents differentially feasibility-verified: the flag introduces no infeasibility); net-positive — the affected spatial-McCormick set (syn15m02hfsg/syn30hfsg/syn40hfsg + casctanks, the out-of-family generality probe) tightens the root dual bound, everything else bound-neutral. A/B root values re-confirmed load-independent (results/issue282/root_lp_probe_ab_reconfirm_*.json).

root_probe_seeds_fallback: bool = False#

Let the root LP probe’s already-proved bound count as “a bound in hand” for the #138 fallback’s own checkpoint rules (DISCOPT_ROOT_PROBE_SEEDS_FALLBACK, default off; §5 bound-changing). Issue #930.

_root_relaxation_lower_bound accumulates candidates in _have and consults it in two places that already exist: rule 1 of _fb_stop (never decline an optional tightening phase while NO valid bound is in hand) and the _sep_budget clamp (once a bound has landed, the separated solve must fit in what is genuinely left of the grant). Both rules ask “do we already have a bound?” — and on the spatial path the answer is often yes before the fallback starts, because the root LP probe in solve_model proved one. _have just never learned it.

This flag seeds _have with the probe bound when — and only when — it passes _admissible_probe_bound’s exact box-equality gate, i.e. it is the identical quantity over the identical box. Measured on hda at a 10 s limit: the fallback re-ran solve_at_node over the same root box for 2.72 s and returned -64473.442402437024, the same value to all 17 digits the probe had proved 3 s earlier — pure duplicate work, and the whole of the fallback’s 4.06 s against a 1.69 s grant.

NOT bound-neutral, which is why it is flagged rather than unconditional. Seeding _have lets rule 2 decline the separated phase once the grant is spent, and the probe bound is not always as tight as that phase would prove: solve_at_node relabels unbounded, time_limit and numerical outcomes as status="optimal" carrying a weak Neumaier-Shcherbina floor (mccormick_lp.py lines 1624/1642/1654), so a starved probe can report a valid but much looser bound than a full separated solve would. status therefore cannot be used as evidence of convergence, and the trade — punctuality for possible bound quality — is exactly the one rule 2 already makes; this flag only widens what rule 2 counts as “in hand”. Never unsound: every seeded value is a valid lower bound over the root box, and it reaches the caller through the same max.

Independent of #930’s other half (re-admitting the probe bound as a max candidate), which is unconditional because it can only tighten the reported bound and costs no wall time.

Panel verdict: stays OFF. Sec.5 bar 1 PASSES, bar 2 does not clear the DISCOPT_CUT_INHERIT precedent (sound but not broadly helpful). Three-arm differential over the 17 non-closing in-repo instances at time_limit=8, 2 reps, arms interleaved per instance and marker-asserted on both trees (discopt_benchmarks/scripts/issue930_root_probe_bound_panel.py, raw results in discopt_benchmarks/results/issue930/):

  • bar 1 cert-clean — over 22 flag ON/OFF bound pairs: 0 lost, 0 looser, 0 tighter, 0 gap_certified True->False, 0 bound past an =opt= oracle or across its own incumbent (6 invariant + 11 oracle checks). The flag did not move a single dual bound. Five closing instances were node-identical and objective-identical across all three arms, confirming both halves are inert where neither code path is reached.

  • bar 2 net-positive — NOT met, and not merely unproven: paired wall on - off is +0.048 s per run (sd 0.369, n=34, total +1.62 s). The flag is fractionally worse, not better. The duplicate solve it removes is real (2.72 s on hda at a 10 s limit, above), but on this corpus the saving does not survive into wall time.

RETRACTION (CLAUDE.md Sec.11). An earlier run of this same panel reported on - off = -0.230 s/run (sd 0.734, total -7.82 s), of which 2.89 s was attributed to contvar and 0.77 s to tls2. That measurement is withdrawn on two counts. It was taken against a baseline on the #75 JAX-removal branch rather than main, so it did not measure the change being shipped; and the contvar component was an artifact — that instance’s baseline is bimodal (11.23 s vs 8.53 s across two reps of the main-based panel), so the “saving” was baseline spread read as a flag effect. The verdict it supported (stays OFF) is unchanged and now rests on a stronger footing: the flag shows no benefit at all, rather than a benefit concentrated in 2 of 17 instances.

What would settle it: a load-gated panel at several time limits over a corpus where the fallback duplicates the probe more often than it does here. Until then the duplicate solve is a known, measured, opt-in cost rather than a silent default change.

rlt1_root_bound: bool = False#

Add an RLT level-1 lower bound at the root for constrained binary QPs (DISCOPT_RLT1_ROOT_BOUND, default off; §5 bound-changing).

Term-wise McCormick envelopes on an indefinite x'Qx are trivially loose — every X_ij drops to its independent lower face, so the root LP bound is ~0 and fathoms nothing (the qap phenomenon, issue #661). RLT-1 multiplies each linear equality a·x = beta of the model by each variable x_p to add the valid identities sum_k a_k X_{p,k} = beta x_p (binary diagonal X_pp = x_p). These constraint-factor products couple the lifted variables across a whole constraint and tighten a constrained binary QP toward its Shor/SDP bound. Purely LP (no SDP solver); solved with the exact vertex simplex, and the surfaced value is the Neumaier-Shcherbina safe dual bound from that solve — rigorous at any conditioning (<= the true LP min for any y>=0 by weak duality), not the raw vertex objective, which on the wide-coefficient RLT LP can drift above the true minimum (issue #145). It joins _root_relaxation_lower_bound’s candidates via max — it can only raise the bound, never loosen it.

Sound by construction: each added row is a product of valid model constraints, so the RLT LP minimum is a valid lower bound (<= the true optimum) and never cuts a feasible point. Measured: qap root 0 -> ~352891 (vs true optimum 388214; HiGHS-ipm gauge) and small synthetic Koopmans-Beckmann QAPs 0 -> optimum via the exact oracle. Default off because the rigorous solve is affordable only up to small/medium n: the exact vertex simplex is fast there (n<=6 QAP in <3 s) but explodes on qap’s highly degenerate all-pairs RLT-1 LP (114k rows), and the POUNCE IPM — the only in-house alternative now that HiGHS is removed — does not converge on these LPs (measured: ~25 iters in 90 s on a 2778x666 RLT LP). It graduates once a fast sparse rigorous LP oracle exists at that scale (see docs/dev/sparse-milp-plan.md §RLT1).

rlt1_max_pairs: int = 60000#

skip (sound no-op) when the all-pairs lift n(n-1)/2 exceeds this (DISCOPT_RLT1_MAX_PAIRS, default 60000 — admits qap’s 25200 pairs, blocks a runaway build on a much larger model).

Type:

Size guard for rlt1_root_bound

rlt1_lagrangian: bool = False#

Compute the RLT-1 root bound by the Lagrangian dual of the coupling rows instead of the monolithic LP (DISCOPT_RLT1_LAGRANGIAN, default off; §5).

Same rigorous RLT-1 bound as rlt1_root_bound, but reached without ever forming the degenerate all-pairs RLT-1 LP: the RLT product identities C z = 0 are dualized and g(mu) = min_{z in P_McC}(c + C^T mu)^T z is maximized by adaptive-target-level subgradient ascent, each step a cheap sparse McCormick solve made rigorous by the Neumaier-Shcherbina safe bound. g(mu) <= RLT-1 opt <= true opt for every mu (weak duality), so each iterate is a valid lower bound; it joins _root_relaxation_lower_bound via max. This is the route that beats the exact simplex’s ~10-20x-per-n wall at qap scale (the inner McCormick LP stays ~0.1 s while the monolithic solve is >25 min). Measured on synthetic QAPs: reaches 100 % of the monolithic RLT-1 bound, target-free, sound. Default off pending the qap-scale entry experiment on the real instance with the sparse inner oracle (see docs/dev/rlt-lagrangian-plan.md §3).

rlt1_lagrangian_max_iter: int = 300#

Subgradient iteration budget for rlt1_lagrangian (DISCOPT_RLT1_LAGRANGIAN_MAX_ITER, default 300). More iterations tighten the bound toward the RLT-1 optimum; each iterate is already a valid lower bound, so an early stop is sound (just looser).

shor_sdp_root_bound: bool = False#

Add a strong-Shor SDP lower bound at the root for constrained all-binary QPs (DISCOPT_SHOR_SDP_ROOT_BOUND, default off; root-only, §5 bound-changing).

The global moment-matrix PSD constraint M = [[1, x'],[x, X]] >= 0 that no local (<=6-var) moment cut can enforce, plus the lifted-equality RLT rows, the McCormick box on X, and the gangster rows (the plain Shor SDP is falsified on this class — unbounded on qap; see docs/dev/issue-661-qap-sdp-entry-experiment-2026-07-17.md). Solved with the first-order conic solver SCS (optional dependency discopt[sdp]; missing solver -> sound no-op), and the surfaced value is the safe dual bound recomputed from the returned multipliers (shor_sdp_safe_dual_bound — the SDP analogue of the Neumaier-Shcherbina safe LP bound: valid for any multipliers by weak duality plus an eigenvalue-shift on the dual slack matrix, so solver convergence affects only tightness, never soundness), never the solver’s approximate objective. Joins _root_relaxation_lower_bound’s candidates via max — it can only raise the bound. Measured (entry experiment): qap root ~0 (McCormick) -> 377098 = 97.1 % of the optimum 388214 (RLT-1 LP gauge 352891, published dual 149106) at ~86 s; exact on brute-forced synthetic Koopmans-Beckmann QAPs. Default off: an ~86 s root cost is a deliberate opt-in, and graduation needs the §5 corpus-wide differential panel.

shor_sdp_max_dim: int = 400#

skip (sound no-op) when the moment-matrix dimension n + 1 exceeds this (DISCOPT_SHOR_SDP_MAX_DIM, default 400 — admits qap’s 226, blocks a runaway SDP on a much larger model).

Type:

Size guard for shor_sdp_root_bound

shor_sdp_time_limit: float = 120.0#

Wall-clock budget in seconds for the SCS solve behind shor_sdp_root_bound (DISCOPT_SHOR_SDP_TIME_LIMIT, default 120 — covers qap’s ~86 s root solve). An early stop is sound: the safe dual bound is valid at any iterate, just looser.

node_bound_mode: str#

"lp" (default, lifted-McCormick LP) or "milp" (legacy nested integer MILP node solve) — DISCOPT_NODE_BOUND_MODE.

Type:

Per-node dual bound

relax_space: str#

Per-node relaxation space for the McCormick dual bound (DISCOPT_RELAX_SPACE, MAiNGO-parity plan §2 P2.3). Values:

  • "lifted" (default, byte-identical to pre-P2.3): today’s lifted McCormick LP with auxiliary columns (MccormickLPRelaxer.solve_at_node).

  • "auto": currently an alias for "lifted" — no structural policy has graduated yet (P2.4). Preserves today’s behavior exactly.

  • "reduced": MAiNGO-style reduced-space McCormick — a Kelley cutting-plane bound over the original variables only (no lifted columns), computed by reduced_mccormick_lp_bound. The evaluator is built once per solve; if the model is outside the sound MCBox scope (UnsupportedRelaxation at build time) the whole solve falls back to the lifted path (logged once, never an error). Per-node, an "unsupported" / "unbounded" status yields no reduced bound for that node (lifted-only); "infeasible" fathoms the node; "optimal" is a valid node dual lower bound and is combined soundly (max with any lifted bound).

  • "hybrid": reserved for P2.5 (Najman-style MC↔AVM per-term lift); raises NotImplementedError until then rather than silently degrading.

CORRECTNESS-CRITICAL: the reduced-space bound certifies the node dual bound. A "reduced" bound is only ever used where its status is "optimal" (valid LB) or "infeasible" (empty relaxed set → fathom); it can only raise a node bound up to (never above) the true box optimum, never cut a feasible point.

node_nlp_stride: int = 4#

Solve the node NLP every k-th node (DISCOPT_NODE_NLP_STRIDE, default 4).

phase2_dbbt: bool = False#

Per-node cheap reduced-cost DBBT + cutoff-FBBT (Phase 2, issue #764).

DISCOPT_PHASE2_DBBT (default OFF, bound-changing / Regime-2). After each spatial node LP solve, run reduce_node — free duality-based bound tightening from the node LP’s reduced costs (d_j > 0 ⇒ x_j ≤ l_j + gap/d_j, gap = cutoff − safe_bound ≥ 0, integer endpoints rounded inward) plus cutoff-FBBT — and feed the tightened box to the children via set_node_bounds. This is BARON’s signature move: cheap reduction from information the node LP already produced (no extra LP solves), the intended replacement for the exhaustive ~2n-LP per-node OBBT on the loose-root / big-tree class.

Distinct from the retired DISCOPT_NODE_REDUCE (removed #581): that flag ran the same reduction but the reduced costs it needed were produced ONLY on the incremental/warm LP path, so on the non-composite-lift class (tanksize etc., which takes the cold path) it silently no-op’d — the #685 net-negative verdict. #764 step 1 added cold-path marginals, so DBBT now actually fires there; this flag re-introduces the sound consumer on top of that prerequisite. Ships OFF until the graduation panel (cert-clean + net-positive), and until the step-3 OBBT-coupling entry experiment shows total-wall-to-close improves without regressing the #685 set.

adaptive_nlp: bool = True#

Adaptive back-off for the strided in-tree node NLP (DISCOPT_ADAPTIVE_NLP, default ON since G2 — flag-graduation convention: =0 restores today’s fixed node_nlp_stride).

TX1 (docs/dev/tenx-plan.md §3). The strided node-NLP is a pure primal heuristic — it fires ONLY where the McCormick LP relaxer supplies the node dual bound and the model is nonconvex (_gate_node_nlp in solve_model); there its objective is never a bound (the LP is), so throttling it can only change incumbent arrival, never the certificate. TX0 measured this bucket as idle waste on integer-heavy nonconvex models (nvs09: 14.3 s skippable, identical proof/bound). Fixed stride 4 keeps re-solving it long after the incumbent has stopped improving.

When ON, the effective stride starts at node_nlp_stride and doubles (capped) after each batch whose strided node-NLP fired but did not improve the incumbent, resetting to the base stride the moment it does. Convex nodes and the no-LP-relaxer path (where the NLP objective IS the bound) are never touched — the gate that admits this back-off is exactly the existing heuristic-only envelope. Sound (heuristic-policy regime, CLAUDE.md §5): every injected point is still sub-NLP/constraint-verified and inject_incumbent enforces strict improvement, so the dual bound and gap certification are byte-identical to the fixed-stride run; only which nodes get a primal probe changes.

continuous_multistart: bool = True#

Stratified continuous multistart at the root for pure-continuous nonconvex models (DISCOPT_CONTINUOUS_MULTISTART, default ON; issue #188).

The primal-heuristic suite is integer-centric: on a model with no integer variables, pump/ILS/diving/RINS/RENS all no-op, the root multistart NLP is skipped on the McCormick-LP spatial path, and the strided node NLP warm-starts from the parent point — zero basin diversification end to end. Measured on the kall_congruentcircles_c51 class (#188): the default path parks at the 1.5371 two-row local packing forever, while 32 stratified starts (~2.8 s, ~90 ms/solve) reach the 1.0730 global basin on every seed tried; the 4 deterministic anchors and the LP-vertex-seeded solves never do.

When ON, solve_model runs primal_heuristics.continuous_multistart once at the end of the root iteration on the spatial McCormick-LP path for nonconvex models with no integer variables: min(64, max(32, 2·n)) stratified starts, deadline-gated between starts and per-solve capped, seed fixed for determinism. Sound (heuristic-policy regime, CLAUDE.md §5): a primal finder only — every point is constraint-re-verified and inject_incumbent enforces strict improvement; the dual bound and certificate math are untouched. Set DISCOPT_CONTINUOUS_MULTISTART=0 to restore the prior behavior.

ils_solve_cap: int = 2#

Sub-NLP solve cap for the integer_local_search objective-descent (DISCOPT_ILS_SOLVE_CAP, default 2 = ON since ILS-DEFAULT, #530-followup).

integer_local_search._objective_improve runs a first-improvement coordinate descent over int_idx × {±1,±2}, each move a full continuous-repair sub-NLP, re-sweeping until its wall deadline. VOLUME-1 (#530) measured its incumbent hit rate at 0 % on every ILS-firing panel instance (the incumbent is already found by the root multistart’s first start) — the descent issues hundreds of no-op sub-NLPs. This caps a single descent to ils_solve_cap × max(1, n_int) sub-NLP solves (a full first-improvement sweep or two — where any real gain lands), keyed on the integer dimension, never an instance name (§0.2).

Default 2, broad-validated on a held-out integer MINLPLib sample (docs/dev/ils-default-validation-2026-07-06.md): 0 lost incumbents, 0 soundness violations, geomean speedup on the ILS-firing subset. Set DISCOPT_ILS_SOLVE_CAP=0 (or ils_solve_cap=0) to restore the old UNCAPPED behavior — the debugging escape hatch, not a dead flag. Sound: capping this descent only ever weakens the incumbent it might find (every point is sub-NLP-verified and re-verified by inject_incumbent); it never touches the dual bound or the certificate (heuristic-policy regime, CLAUDE.md §5).

ils_eval_budget: int = 20000#

Evaluation cap for the root integer_local_search (DISCOPT_ILS_EVAL_BUDGET, default ON; issue #912).

The root integer local search used to bound its own extent with a wall clock (time_budget=min(5.0, 0.15 * time_limit)). Its descent routinely never converges, so how far it gets — and therefore the incumbent it hands the tree — was a function of machine speed: #912 measured gear2 closing in 3 nodes at a 5 s budget and 91 nodes at 3 s, with the default sitting exactly on that cliff, and reproduced the flip by scaling the process clock. A search whose result depends on how fast the box is cannot sit on the certificate path, and it silently invalidates every “node counts unchanged” verdict this repo relies on (CLAUDE.md §5, bound-neutral regime).

The search now counts operations instead (discopt._work_budget) and stops at the same point on every machine. This field caps the cheap kind: constraint/objective evaluations, 0.7-2.7 us each as measured. The solve deadline is still passed down as a backstop so time_limit is honoured; it decides when to stop, never how much work to do.

Set this and ils_solve_budget to 0 to restore the legacy wall-clock gate — the debugging escape hatch, not a dead flag. Sound either way: ILS is a pure incumbent finder (every point is sub-NLP-verified and re-verified by inject_incumbent), so changing its extent can only change which feasible point it finds, never the dual bound or the certificate.

ils_solve_budget: int = 128#

Sub-NLP-solve cap for the root integer_local_search (DISCOPT_ILS_SOLVE_BUDGET, default ON; issue #912).

The companion to ils_eval_budget, capping the expensive kind: continuous-repair NLP solves, 1.9-104 ms each as measured. Whichever cap is reached first ends the search.

They are separate on purpose, and the separation is a measurement, not a preference. Converting both to one currency at their geomean cost ratio (12 364) was tried first and produced a real regression: nvs09’s evaluation-dominated search was starved (5 -> 29 nodes) at a budget that already gave the solve-dominated syn05hfsg three times its legacy wall time. The ratio varies 27x across the corpus, so one number cannot price both. Full data in docs/dev/work-budget-calibration-2026-08-01.md.

Distinct from ils_solve_cap, which limits sub-NLPs per objective descent keyed on the integer dimension; this one bounds the whole call.

gdp_bound_pretransform: bool = False#

Apply gdp.bound_pretransformation inside the big-M and mbigm lowerings (DISCOPT_GDP_BOUND_PRETRANSFORM, default OFF; #1775 G2.4, bound-changing on the "big-m" / "mbigm" routes).

A disjunct row bounding one variable becomes part of sum_k L_k y_k <= x <= sum_k U_k y_k on the lowering’s own selectors (exact under SELECT_ONE) instead of a big-M row. The transformation itself is NC and user-selected (transformations.apply_to("gdp.bound_pretransformation", m)); only its automatic use is gated here. Default OFF until the §5 flag panel (CLAUDE.md) shows it cert-clean and net-positive; the audit row is in docs/dev/flag-retirement-audit.md.

mbigm_conditioned: bool = False#

Multiple big-M conditioned on each disjunct (DISCOPT_MBIGM_CONDITIONED, default OFF; #1775 G2.3, bound-changing on the "mbigm" route).

Today gdp_method="mbigm" gives each disjunct row one M: an exact LP of its body over the global linear rows and the box. With the flag ON, a row c of disjunct k in a SELECT_ONE disjunction gets one M_{c,j} per other disjunct j – the exact-LP bound of its body over the global linear rows plus disjunct j’s linear rows – and becomes body <= sum_{j != k} M_{c,j} y_j (Pyomo’s gdp.mbigm). Exactly one y is 1, so the row is exact at every integer point; each M_{c,j} is never above today’s M, so the LP relaxation is at least as tight. Sizing on the gdp_parity panel (#1775, 2026-10-20): conditioning tightens 35-60 % of the rows on 6 of 23 measured instances.

State: default OFF, panel owed (owner decision 2026-10-20; tracking item #1775 G2.3; audit row in docs/dev/flag-retirement-audit.md). The entry panel ran 2026-10-20 (21 gdp_parity instances, mbigm, 60 s, OFF/ON interleaved) and was cert-clean but not net-positive: node counts fell on med_term_purchasing, ex1_linan_2023 and small_batch, but modprodnet lost its certificate and gdp_col, mod_hens and spectralog lost or worsened their incumbent. Blocker: the conditioning LPs run before branch-and-bound and ignore the solve’s time limit – the deterministic budget of 1,000 LPs does not count _compute_big_m_lp()’s own LPs and is not a deadline (stranded_gas spent >10 min in setup). What would change it: deadline-aware conditioning (fall back to today’s M once a share of the time limit is spent), then the corpus panel decides graduate vs retire. That panel ran before the #1824 review’s soundness fixes (rigorous Neumaier-Shcherbina M, no term dropped on a positive maximum, array-valued rows keep today’s M), so it is owed again on the fixed route.

hull_conic_quadratic: bool = False#

Hull a separable convex quadratic row as a second-order cone (DISCOPT_HULL_CONIC_QUADRATIC, default OFF; bound-changing on the "hull" route).

Today every nonlinear disjunct row gets the eps-perspective yhat * g(v / yhat) - eps * g(0) * (1 - y) <= 0. For g = sum_i q_i (x_i - a_i)^2 - R (q_i > 0, R >= 0: a ball or axis-aligned ellipsoid) the flag emits the smoothed cone sqrt(S + delta^2) <= sqrt(R + delta^2) y + delta (1 - y), S = sum_i q_i (v_i - a_i y)^2, instead: no eps division, exact at every integer point, a relaxation of the perspective between them (_conic_hull_body has the algebra), and certified convex by the convexity detector, so a hull of convex disjuncts keeps the convex route. A child’s cone row in a nested hull is passed up unchanged: it contains the exact nested hull of its leaf and is exact at every integer point.

Entry measurement (flat three-circle disjunction, 5 objectives x 3 interleaved reps, all optimal and equal to big-M): 76.8 s per rep with today’s hull -> 0.54 s smoothed (11.3 s with the unsmoothed cone), certified convex at the root, 1 node; big-M 0.1 s. State: default OFF, panel owed; audit row in docs/dev/flag-retirement-audit.md.

gdp_sumover: bool = True#

Teach the GDP/relaxation expression walkers the indexed-summation node SumOverExpression (dm.sum(f(i) for i in S)) (DISCOPT_GDP_SUMOVER, default ON – graduated by the §5 panel below; opt out with DISCOPT_GDP_SUMOVER=0, which restores the pre-#1154 walkers byte for byte. #1154).

SumOverExpression holds its already-expanded term list in .terms. Six walkers in discopt._relax.gdp_reformulate had no case for it, so a disjunct body built with dm.sum was under-reported as having no variables (_collect_variables), non-linear (_is_linear), unbounded (_bound_expression) and un-evaluable at the origin (_body_at_zero). On main that costs three loud refusals: auto/big-m cannot bound the body, hull cannot form g(0).

With the flag ON the six walkers treat Σ[t1, …, tn] exactly as the left-folded chain t1 + … + tn — the desugaring the modeling layer could equally have produced — so nothing about the reformulation’s mathematics is new; only the node type is newly recognised. The invariant is machine-checked per walker in python/tests/test_1154_gdp_sumover_hull.py.

Graduated default-ON (§5, 2026-09-04) on a panel that meets both bars; full tables in docs/dev/issue-1154-gdp-sumover-panel-2026-09-04.md.

Cert-clean. The flag is structurally inert on the .nl corpus – the node is created only by the Python modeling API’s dm.sum and the .nl reader never emits one, measured at 0 occurrences in 33 376 DAG nodes over all 66 vendored instances – and the A/B differential is byte-identical on 63/66, the other 3 reproducing their whole difference within a single arm (a role-1 time_limit truncation artifact, #1116). Scored against the oracle: 52 instances, 0 bound violations, 0 primal violations. On the class where the mechanism fires (108 generated GDP models x 3 routes x 2 arms = 648 solves, every incumbent feasibility-verified in numpy against the original disjunction): 0 invalid bounds.

Net-positive. Three loud refusals on the issue’s repro become three certified optima at the true -30.0. auto and big-m are bit-identical between a Σ[...] body and the equivalent folded chain on all 108 cases; on hull with a nonlinear body the Σ form certifies 46/54 against the chain’s 29/54, sd 0.00 over 3 interleaved reps on a quiet machine. 731 GDP/OA/Benders/GBD/MPEC tests give identical results in both arms.

Note the ordering that PR #1150 got wrong: widening _is_linear alone made hull emit the disjunct body globally with its selector coefficient collapsed to zero (all_vars was empty, so no disaggregated variables were created) and return a dual bound of −3.0 on a model whose true minimum is −30.0. The walkers must move together; the independent-walker cross-check in _reformulate_disjunction_hull now refuses loudly rather than emit that row.

disjunctive_config_bound: bool = False#

Root disjunctive configuration bound for the gated-configuration class (DISCOPT_DISJUNCTIVE_CONFIG_BOUND, default OFF; #732 Stage 2).

When the integer-multilinear reform (#707) applies, enumerate the reform’s configuration-indicator patterns at the root, bound each configuration box by FBBT -> OBBT -> LP with best-first unit-peeling on the configuration count variables, and floor every optimal node bound at the min over configuration leaves — a valid bound by partition (anytime-valid: unprocessed leaves inherit the caller’s existing root bound). Measured (ex1252, plan doc Stage 2): the standalone root pass certifies 37945 at a 48-leaf budget (tree at 400 nodes: 16304) and 63080 at 120 leaves once the Stage-4 spatial bisection engages (through the ~48k plateau — the #721 acceptance bar); end-to-end the reported global dual goes 0.0 -> 42725 (240 s solve) and 0.0 -> 74915 (600 s solve, deadline-governed leaf budget). Default-OFF pending the CLAUDE.md §5 corpus differential panel.

obj_branch_priority: bool = True#

Prioritize branching on objective-defining variables (DISCOPT_OBJ_BRANCH_PRIORITY, default ON).

Graduated per T2.6 with 3 consecutive green held-out verdicts (composed with the density LU route): BR-3 #602 (verdict 1), FLAG-GRAD #612 (verdict 2), and the P0 SPATIAL-CERT re-run (docs/dev/p0-spatial-cert-2026-07-10.md, verdict 3 — incorrect 0, oracle-cross 0, cert-loss 0; both-certified nodes 1092 -> 1054). Set DISCOPT_OBJ_BRANCH_PRIORITY=0 to restore the old default.

sos1_selector_branch: bool = False#

Spatially branch continuous SOS1 selectors before drilling aux-binaries (DISCOPT_SOS1_SELECTOR_BRANCH, default OFF; issue #196).

A continuous one-of-N selector s (member of a selection row Σ s_i = 1, upper-coupled to a 0/1 indicator by s ≤ y, and in a nonlinear product term) that stays spread across a multi-line box keeps the McCormick bound of the gated products pinned near 0. When on, _sos1_selector_vars() detects such selectors and the Rust tree branches one spatially (box-midpoint) with precedence, concentrating the selection so a single product is forced positive (ex1252: an ambiguous box’s bound 12658 → ~67–83k once a selector is pinned).

Branch-ORDER metadata only (never a bound/feasibility input), so it cannot change a bound’s validity — the midpoint split is a sound cover and its width-halving keeps the search complete. Default OFF pending a corpus differential panel (CLAUDE.md §5, incorrect_count = 0 + net-positive) before any graduation.

lp_warmstart: bool = True#

Warm-start the node LP from the parent basis (DISCOPT_LP_WARMSTART).

lp_row_logicals: bool = False#

Marshal every LP row with its own logical column, equalities included (DISCOPT_LP_ROW_LOGICALS); see discopt._relax.std_form for the layout and why the engine needs it.

OFF reproduces the two pre-consolidation layouts exactly: the Model.solve extractors give an equality row no column (so the basis is short of m, which turns off the dual warm start, GMI separation and slack substitution), while milp_simplex._marshal_std_form splits each equality into two opposing <= rows to get the shape another way.

Default OFF pending the graduation panel (CLAUDE.md §5). It is bound-CHANGING in effect: the columns it adds are fixed at zero, so the feasible set and every valid bound are unchanged, but restoring the warm start and the cut families changes which bounds the search actually proves, and with them the node counts.

lp_cold_dual_start: bool = False#

Start a COLD pure-LP solve from the sign-matched dual-feasible slack basis even with no deadline (DISCOPT_LP_COLD_DUAL_START).

solve_lp_warm_std already builds that basis (_dual_start_slack_basis), but #928 engaged it only when the caller passed a finite time_limit, because #928’s motivation was an anytime bankable floor rather than speed. Since lp_warm_deadline is itself default-OFF, no deadline reaches the LP on the default path, so in practice every cold node LP takes the Rust cold PRIMAL loop — the loop whose own comment reads “a dense, degenerate lifted-McCormick LP can otherwise grind toward max_iter and run uninterruptibly for minutes” (lp/simplex/primal.rs).

It does. Measured on the QPLIB relaxation LPs, one variable changed (the start basis) and no deadline on either arm, so both run to optimality and the LP optimum is a control (scratchpad/qplib_run/coldstart_ab.py):

instance

RLT

dual-slack start

cold primal (pre-flag)

QPLIB_1157

off

0.27 s

0.18 s

QPLIB_1157

on

6.17 s optimal

>150 s, iter-cap fail

QPLIB_1493

off

0.34 s

0.30 s

QPLIB_1493

on

2.91 s optimal

11.79 s, iter-cap fail

QPLIB_1507

off

0.19 s

0.38 s

QPLIB_1507

on

0.56 s

0.88 s

QPLIB_1143

off

0.88 s

1.63 s

QPLIB_1143

on

5.17 s

14.59 s

The dual start wins 6 of 8; both losses are under 0.1 s absolute. On 2 of 8 the cold primal does not merely run long, it fails — max_iter exhausted, None returned, which then cascades into the equilibrated retry and the ~170x-slower cold solve_milp. Wherever both arms returned, the objectives agree to <=3e-12 (and to HiGHS likewise), so this is bound-neutral on those.

Why the degeneracy: an equality row reaches the LP layer as two opposing <= rows, both tight at every feasible point. Relaxations rich in linear equalities (constraint-factor RLT above all — see rlt_lineq) are therefore massively primal-degenerate, and the cold primal stalls where a dual simplex started dual-feasible does not. That is a property of the row structure, not of any instance.

Default off pending the graduation panel. It is not provably bound-neutral: a different optimal basis can be returned on a degenerate LP, which can move downstream branching, and the iter-cap cases change status None -> optimal (a strict gain, but a change). Ineligible LPs — an open bound on the side the objective sign selects — keep the primal path exactly as before.

native_nlp_primal: bool = True#

Run a local NLP inside the native spatial kernel’s tree (#1522, DISCOPT_NATIVE_NLP_PRIMAL).

The kernel’s only internal source of incumbents is an LP vertex at which every lifted term is McCormick-tight to mccormick_tol; everything else depends on the one-shot NLP seed taken before the search. When the seed returns nothing and the optimum lies on a curved manifold, the tree cannot close. Measured on MINLPLib prob09 with no seed: 100k nodes produced incumbents 2.3e-3 above an optimum of 0 that the root bound (t >= 0 from FBBT) had already proven, while the same kernel seeded with a local-NLP polish of its FIRST such incumbent certified at node 1.

With this ON the kernel calls back into Python (_native_kernel_primal_hook) when it accepts a new incumbent and at nodes 1, 2, 4, 8, …; the hook runs one KKT re-solve of the lift from the node’s LP point and returns the point only if it verifies on the lift AND on the pre-reform model. Primal only: the reported bound is still the min of the closed regions’ rigorous bounds. On a model with integer columns a scheduled call waits for an LP point integral on every integer column with an untried assignment (#1990, Rust DISCOPT_HOOK_INTEGRAL_POINTS).

Default ON since #1990 (=0 opts out), graduated together with DISCOPT_KERNEL_MULTILINEAR_SPLIT (docs/dev/performance-plan.md §97.1). Panel, 424 instances at 30 s: fired on 52, cert-clean, both-certify wall 1160.2 -> 1152.5 s (genpooling_lee1 26.1 -> 21.5 s); nous1 is the measured cost, still certified in 3/3 reruns but 21 -> 28 s. With the split it certifies ex1252 and ex1252a, which neither flag does alone.

deterministic: bool = False#

Make the search a function of the model rather than of machine speed by rendering every role-2 wall budget inert. DISCOPT_DETERMINISTIC, default OFF; also reachable as Model.solve(deterministic=True).

#912 draws the line this flag acts on. A clock that answers “when do we stop?” — the user’s time_limit — is correct by definition (role 1). A clock that answers “how much work do we do?” — a sub-budget carved as a fraction of time_limit, or a fixed number of seconds handed to a stage — makes the ANSWER a function of how fast the machine happens to be that minute. _work_budget.py calls that “a correctness-of-process bug, not a performance detail”.

#1116 measured the consequence. kriging_peaks-full200 at max_nodes=1, same process, same binary, no user time pressure, returned root dual bounds of −25371.8 / −28852.0 / −28072.6 — a 14 % swing — with the incumbent bit-identical. Neutralizing the wall budgets made the same solve reproduce exactly (-1044.819… twice at max_nodes=1, -754.478794470719 twice at max_nodes=300), and tighter than every wall-bounded run.

When this is on, role-2 budgets become None/math.inf at the 27 sites that carve them (solver._role2_budget / _role2_deadline / _role2_horizon, plus the integer-ratio dive in _relax/mccormick_lp.py) and each stage is bounded by the deterministic caps it already carries (round counts, iteration caps, the node budget). Expect a run to take longer: nothing truncates a stage early any more.

A 28th site joined them with #1187, in a shape the first three helpers could not express: a slice handed to a nested solve_model() as its time_limit, where neither None nor math.inf is a legal value. solver._role2_slice returns the caller’s own time_limit there — elapsed-independent, and still finite, so the nested solve keeps a role-1 bound. The gate was the NLP-BB root RENS budget, and it was measured returning three incumbents 25 % apart (55092.52 / 46785.55 / 41573.26) on clay0303hfsg at an identical 27 nodes with the dual bound agreeing to 12 significant figures — this flag on, in one process, on one binary. As in #1116, routing it made the instance find a better incumbent (26669.11) than any wall-truncated repetition.

Fixing that exposed a second gate the 25 % spread had been hiding, and it is the more instructive one: the GDP-config constructor’s plan wave (_relax/primal_heuristics.one_hot_config_subnlp) already carried a deterministic bound — _WAVE_SOLVE_CAP = 48 — but _gdp_config_deadline’s 15 s slice always expired first, so the cap was decorative and the extent was machine speed: 36 / 37 / 38 / 37 plans across four repetitions, and a different prefix of the plan order picks a different disjunct. Under the flag the wave is now bounded by its own cap, and the caller’s deadline is left to the dive that follows it, which is what still bounds the stage overall. clay0303hfsg then reproduces bit-exactly — node count, incumbent and dual bound — in 76 s against its 120 s limit.

The lesson generalizes past this gate: a stage can carry a perfectly good deterministic cap and still be nondeterministic, because a wall budget upstream retires first. A cap that is never reached is not a bound.

What the flag does not cover, and why. Two role-1 mechanisms are left alone on purpose, because neutralizing them would let preprocessing overrun the user’s time_limit without bound — trading a reproducibility bug for a broken role-1 promise, which CLAUDE.md §1 does not permit:

  • the phase-entry gates (_deadline_exhausted() / _remaining_budget() > x), which decide whether an optional preprocessing phase starts at all;

  • the two POUNCE funnels’ max_wall_time = min(30.0, caller_limit) stall backstop.

So the guarantee is: a solve reproduces when the role-1 budget never binds — i.e. it terminates on work (max_nodes/gap) with real slack against time_limit. A run cut short by time_limit, or run on a machine slow enough that a phase-entry gate flips, is not reproducible and does not claim to be.

That is not a footnote for a panel author, it is the panel’s admission rule. This flag cannot equalise work on a run that terminates on the wall clock, because the terminating condition IS the wall clock. Measured on beuster at time_limit=120 with this flag on: two builds differing only in Python marshaling cost issued 3858 OBBT probe LPs against 942 — 4.1x the work — for the same 3 nodes and the same bound, both ending status=time_limit. A gate that compares such rows is measuring the budget; #1180’s sweep did, on 13 of 66 rows, and manufactured a reproducible “0.516x regression” that re-measured as a 5x-more-nodes, 30 %-tighter-bound improvement. The benchmark harness now refuses those rows and reports them as unmeasured rather than passing them (discopt_benchmarks/utils/cert_neutrality.wall_limited_rows).

The residual was measured not to bind on the reproduction instance: the 30 s POUNCE cap was live and real during the arm that reproduced bit-exactly, and the solve finished in ~7 min against the default 3600 s limit.

budget_saturation: bool = False#

Stop role-2 sub-budgets from growing without bound in the caller’s time_limit. DISCOPT_BUDGET_SATURATION, default OFF (bound-changing, CLAUDE.md §5).

#1153 is the harm. On nvs19 a 30 s budget reached -1098.2 over 38 403 nodes and a 60 s budget reached -1001.2 over 7 619 — doubling the budget made the answer worse and explored 5x fewer nodes. A user who doubles time_limit and gets a worse answer has no way to reason about the solver, and node throughput falling as the wall budget grows is measurable independently of whether that instance is ever solved.

The mechanism is #1116’s role-1/role-2 split read one step further. Role 1 (“when do we stop?”) is the user’s time_limit. Role 2 (“how much work does this stage do?”) is a sub-budget carved as a fraction of it. Carving role 2 out of role 1 is fine as long as it saturates: a root stage whose grant keeps growing with the caller’s budget goes on separating cuts that every subsequent node LP then carries, so the per-node cost rises with time_limit and the tree covered by the remaining budget shrinks.

Under this flag each such sub-budget is capped at the value it would take at a ROLE2_SATURATION_S (150 s) role-1 budget, so beyond that point every additional second the caller grants goes to the search. 150 s is not a new number: it is the reference the root OBBT grant already carries (min(max(0.1 * time_limit, 2.0), 15.0)), and it is the loosest choice consistent with the ceilings the sibling root stages already have — the conservative direction for a default-path change. Below it nothing changes at all, which is why the flag is inert on short solves.

Sound in every arm: each capped stage is optional tightening whose truncation only ever yields a weaker relaxation (fewer cuts, fewer OBBT rounds), never an invalid one, so no bound can rise above its true value and no certificate can be fabricated. The flag can only change which valid answer path is walked.

heuristic_entry_share: bool = False#

Require an optional root primal heuristic to fit a bounded SHARE of the remaining budget, not merely to fit inside it. DISCOPT_HEUR_ENTRY_SHARE, default OFF (heuristic-policy, CLAUDE.md §5).

The measured #1153 mechanism. heatexch_gen2, three repetitions, zero spread: a 5 s budget explores 7 nodes and a 10 s budget explores 3. The layer profile says where the extra five seconds went — root_time is 3.5 s of 5.1 s at the small budget and the entire 10.1 s at the large one — and the NLP probe names the consumer: at 5 s the feasibility pump never starts, at 10 s it starts and its two sub-NLPs spend 6.4 s of the 10 s budget and return no incumbent (identical obj=None, identical bound, in both arms). Doubling the budget bought one extra heuristic that consumed 64 % of it and produced nothing, and the tree paid for it.

The entry gate is what admits that. It refuses a heuristic only when the time left cannot absorb one whole solve of the largest size seen so far — so a heuristic may consume up to 100 % of the remaining budget and still be admitted. Crossing that threshold therefore costs more than the budget increment that unlocked it, which is precisely a non-monotone step.

The repository already applies the right doctrine one role over: an improver-role heuristic must fit a success-weighted, node-proportional contingent (_improver_allowed). The finder role is deliberately exempt, because securing a first incumbent takes priority — but priority should mean runs first, not may consume everything. This flag keeps the exemption and bounds it: a finder may start when its measured cost fits HEURISTIC_ENTRY_SHARE of what is left. A large budget still admits it; a budget that would be eaten by it does not.

Scope. The share is applied only where the caller passes finder=True — the two feasibility-pump entries. _root_heur_nlp_entry_ok is the shared gate for thirteen root-heuristic entries, several of them improver-role (enumerate, integer_box_search, node diving), and those already answer to their own contingent; dividing there unconditionally double-gated them.

Known structural limit, and why it cannot graduate as written. _mean_heur_nlp_cost() returns its 2.0 s default until a heuristic NLP has actually been observed, so at the FIRST call this gate reduces to “is more than 2.0 / share seconds left?” — on any model, independent of the pump’s real cost, which was measured at 6.4 s. That first call is the whole effect on the 5-10 s rungs where #1153’s harm lives, so the rule is acting on a 3x-wrong estimate exactly where it matters most.

Sound: every gated call is a primal heuristic, so refusing one can change which incumbent is found and when, never the dual bound or the certificate (§0.3 heuristic-policy). DISCOPT_ROOT_BUDGET_GATE=0 still disables the whole gate, flag or no flag.

root_fixpoint: bool = True#

T2.3) at the end of iteration 0: iterate {FBBT-with-cutoff, OBBT/DBBT-with-cutoff} to a fixpoint on the root box, refreshing the root cut pool + incremental engine base from the tightened box. DISCOPT_ROOT_FIXPOINT, default ON (GRADUATED per #581 under the one-successful-graduation-gate-run policy — CLAUDE.md §5). The graduation gate (graduation_gate.py –flags root_fixpoint, held-out N=20 seed 0 + 41-instance cert panel) returned cert-clean (incorrect_count 0, 0 cert violations, objective/optimal-status enforced) and net-positive (benefit 29% — crudeoil_pooling_ct1, ex5_3_3, powerflow0014r, qap, eg_all_s — with 0% regression). Set DISCOPT_ROOT_FIXPOINT=0 to restore the old default OFF.

Type:

Run the cutoff-aware root branch-and-reduce fixpoint (cert

anytime_root_build: bool = False#

Make the root-relaxation fallback build anytime/incremental so its dual bound accrues and the build can honor the grant (DISCOPT_ANYTIME_ROOT_BUILD, default off; §5 bound-changing; issue #694).

#654 left a measured floor: on a class of large sparse network-design/QAP/graph -partition MINLPs (sonet*, qap, eg_all_s, super3t) the fallback’s dual bound is produced by a single uninterruptible McCormick-LP build (sonet23v4: 16.8 s, not bounded by the solve’s time_limit), so solve(time_limit=2) still took 24.5 s — truncate the build and you lose the bound (baron-gap-plan.md §8.1), don’t and you blow the budget. This flag dissolves that fork: when on, _root_relaxation_lower_bound passes a build_deadline (its own grant) to the base build_milp_relaxation and the separated solve_at_node build, so the constraint-row loop stops adding rows once the grant is spent and the partial relaxation is solved for a valid (weaker) bound.

Sound by construction: a relaxation with FEWER constraint rows is still a valid outer approximation, so its LP minimum is a valid lower bound — dropping rows can only weaken, never falsify (the “weaken but never falsify” property of baron-gap-plan.md §8, and the #694 entry experiment: a finite bound exists by 8–45 % of build on every tested structure, because the objective is fully linearized before the constraint loop). It does NOT re-add the Rust LP native deadline (§8.2, TX2b): this truncates the Python relaxation build, never the LP solve. Default off pending the §5 corpus-wide differential panel (flag ON vs OFF; incorrect_count = 0, no bound above its reference optimum, no certification regression, incumbents feasibility-verified) AND net-positive on the #654 class — with the must-not-regress bounds casctanks 5.698, super3t −1.0, sonet23v4 −53974.375 kept sound. Entry evidence: docs/dev/issue694-anytime-build-entry-2026-07-17.md. NOTE: with the flag on the fallback bound becomes timing-dependent (an anytime algorithm), so it is not bit-reproducible run-to-run; the default (off) path is unaffected and stays deterministic.

root_build_deadline: bool = True#

Deadline the base root-relaxation build_milp_relaxation (DISCOPT_ROOT_BUILD_DEADLINE, default ON — GRADUATED per §5, set =0 to opt out; §5 bound-changing; issues #832/#814).

The #694 anytime_root_build flag truncates the separated build but its companion base build was deliberately left WHOLE — so on large ill-conditioned instances the base build alone overruns the grant several-fold. Measured (gastrans582_mild11, budget 3.0s): the base DCP build takes ~10.5 s and the fallback returns None after ~15.5 s (5.2x overrun) — 93 % of the time is the Python DCP relaxation build, not the Rust LP (2.77 ms factorize); see #832.

When on, _root_relaxation_lower_bound passes a build_deadline (its own grant) to the base build, so its constraint-row loop stops adding rows once the grant is spent. Sound: the objective is fully linearized before the constraint loop, so a prefix of rows is a valid weaker outer relaxation (dropping rows only enlarges the feasible set -> the LP min stays a valid lower bound); if a dropped constraint un-bounds an objective cost column the existing _objective_bound_valid gate returns None (weaker, never falsified). On gastrans582 the truncated base build is still obj_bound_valid=True and yields a valid weaker bound in ~budget instead of None after 5x the grant.

Bound-changing (a truncated base build can weaken a bound or drop it to None). GRADUATED default-ON per §5 (one passing graduation-gate run suffices): the graduation_gate.py --flags root_build_deadline panel (held-out N=40 seed 0 + cert panel) returned eligible — soundness ok (0 violations, no bound above its reference optimum), cert-neutral (certified objective + optimal-status enforced), net-positive (benefit 23% / regression 8.6%, and 2 of the 3 “regressions” are node-count-only labels on large wall wins: sonet24v5 33.6→14.5s, sonet25v6 38.2→19.5s). Set DISCOPT_ROOT_BUILD_DEADLINE=0 to restore the legacy whole-base-build path. Like #694, with the deadline active the fallback bound becomes timing-dependent (an anytime algorithm), so it is not bit-reproducible run-to-run; the =0 opt-out path stays deterministic.

root_build_abort: bool = True#

Abort the base root-relaxation fallback build past the fallback’s grant (DISCOPT_ROOT_BUILD_ABORT, default on, opt-out =0; §5 bound-changing; issue #1757). Only read while root_build_deadline is on.

root_build_deadline truncates the base build’s constraint rows, but the objective walk that precedes them is not interruptible by it. On a dense nonconvex quadratic objective that walk is nearly the whole build: measured on QPLIB_3709 (600 vars, 165,300 products) at time_limit=30, the #654 arm granted the fallback its last few seconds and the base build ran 36.7-40.1 s, so the solve returned at 57-60 s. When on, abort_deadline = grant + one fallback reserve (solver._ROOT_BUILD_ABORT_GRACE_S, 3 s) is also passed: past it the build raises RelaxationBuildAborted, is discarded whole (never used truncated), and the fallback returns None. The grace is measured, not defensive: an abort AT the grant dropped casctanks’ and hda’s bounds at time_limit=5 (1.258 and -141697, built 0.1-0.2 s past a spent grant).

Sound: no relaxation is ever used partially, so a reported bound is unchanged or absent, never weaker-than-claimed. Bound-changing because an aborted build loses a bound the overrunning build would have produced. It can only fire where the base build alone outlives its grant by more than the grace.

Graduated on introduction (§5 panel, 2026-10-17, interleaved, 3-4 workers): 66 in-repo instances x {5, 20} s, 595 checks, 0 violations, never fired (the one certificate the ON arm lost, tspn05 at 20 s, re-certified 3/3 at 23 nodes in a control: load noise at load average ~20-60); 24 dense QPLIB instances (obj_quad_nnz >= 50000) x 30 s, 28 checks, 0 violations, 0 certificates lost, total wall 916.3 s -> 786.1 s. It fired decisively on four, which OFF ran to 58.9-68.0 s and ON returned at 30.3 s: QPLIB_2880, 3347, 3709 and 6597. Each of those OFF arms reports the bound 0.0 that ON does not — a bound obtained 96-127% past the caller’s time_limit, which is the defect, not a result the limit bought. The abort is only prompt because a DCP check inside the build polls it too (uniform_relax._Builder._dcp): before that, QPLIB_2880 raised 20 s late.

node_build_abort: bool = True#

Abort a solve_at_node cold relaxation build past its deadline (DISCOPT_NODE_BUILD_ABORT, default on, opt-out =0; §5 bound-changing; issue #1924).

build_deadline truncates only the constraint-row loop, so the objective walk and any single constraint’s walk are uninterruptible by it; the node loop’s cold build had no deadline at all unless DISCOPT_NODE_ROUND_BUDGET was on. Measured at time_limit=60 (MINLPLib): chimera_mis-01 (2,032 vars, objective only), t1000 and gastrans582_cold13 sat inside one cold build – convexity certification – past 135 s.

When on, the root LP probe and the root cut-pool builds get abort_deadline = their root-setup build deadline + one fallback reserve (_ROOT_BUILD_ABORT_GRACE_S), and the node loop’s builds get the solve deadline + the same reserve, mirroring root_build_abort (#1757). Past it the build raises RelaxationBuildAborted (polled every 256 node relaxations and before each curvature sub-box), is discarded whole, and the node reports status="aborted" with no LP bound: a node whose NLP also failed is kept open at its parent bound, never fathomed on the failure sentinel. Sound: no relaxation is used partially, so a bound is unchanged or absent, never weaker-than-claimed. Bound-changing because an aborted build loses a bound the overrunning build would have produced.

Graduated on introduction (§5 panel, 2026-10-24, interleaved, frozen snapshot): 70 in-repo instances x 10 s, 206 oracle checks, 0 violations, 0 certificates lost, never fired (total wall 347.5 s OFF / 347.1 s ON). Targeted at 60 s (OFF / ON, same status and bound): chimera_mis-01 90.9 / 62.4 s, autocorr_bern40-40 129.3 / 61.7 s. Row in docs/dev/flag-retirement-audit.md.

root_setup_build_deadline: bool = True#

Bound the pre-B&B root-setup relaxation builds by the solve’s own deadline, minus the root-fallback reserve (DISCOPT_ROOT_SETUP_BUILD_DEADLINE, default ON — GRADUATED per §5, set =0 to opt out; §5 bound-changing; issue #1152).

#832 gave the root-relaxation fallback’s builds a deadline; the root-setup phases that run BEFORE it — root OBBT’s per-round envelope rebuild, the root LP probe, the root cut pool — still build unbudgeted. Each of those phases polls a deadline between its LP solves but not inside the build, so a build that starts just before the deadline runs to completion past it. Measured on casctanks at time_limit=5 (in-repo corpus): root OBBT starts its round build at t=4.39 s with 0.61 s left and spends 1.85 s in it, so the solve returns at 6.4 s (1.29x) and — the second half of the defect — the #654 short-circuit then finds _remaining_budget() == 0 and skips the root-relaxation fallback entirely, so the solve reports bound=None where a slice of a second would have proved one.

That is issue #1152’s contradiction in miniature: “honour the deadline” and “keep the dual bound” read as opposing contracts only because the long root operations are all-or-nothing. When on, solve_model threads one absolute root-setup deadline (time_limit minus the _ROOT_FALLBACK_RESERVE_S slice the fallback needs) into those builds as the build_deadline the #694/#832 anytime mechanism already implements: the constraint-row loop stops at the deadline and the partial relaxation is still a valid outer approximation. It also declines to start the native-kernel spec build (two whole relaxation builds whose row sets must correspond, so it is not truncatable) once that deadline is spent.

Sound by construction, in each direction the truncation reaches:

  • OBBT tightens x_i to the optimum of min x_i over the relaxation polytope. Dropping constraint rows only ENLARGES that polytope, so the LP optimum moves outward and the tightening is weaker — never invalid. The #208 cascade’s carried aux-column bounds are keyed by column index and a truncated build has a different lifted layout, so they are neither applied to nor captured from a truncated build.

  • The root LP probe decides whether to keep the relaxer and banks a bound that _root_relaxation_lower_bound re-gates on exact box equality; a truncated build can only make that bound weaker (fewer rows), and #928’s cut-short objective floor keeps it finite.

  • The root cut pool only adds cuts; a truncated build separates fewer of them, which loosens per-node bounds and never invalidates one.

Bound-changing (a truncated setup build can weaken the root box, the probe bound or the pool), hence flag-gated. Off is byte-identical: no build deadline is computed, no entry gate is consulted, and _setup_remaining_budget returns the plain remainder.

GRADUATED default-ON per §5 (one passing graduation-gate run suffices) on the in-repo corpus differential — 66 instances x time_limit {5 s, 20 s}, both arms interleaved per instance, 132 pairs / 559 comparisons (scratchpad/i1152/panel.py + panel_report.py):

  • cert-clean: 0 soundness violations — no dual bound above its reference optimum, none crossing its own incumbent — 0 certification regressions and 0 certified objectives changed.

  • net-positive: 2 bounds RECOVERED from None (casctanks -> 1.2584, bchoco08 -> 1.0) and 3 tightened (4stufen 18770 -> 19055, beuster 5942 -> 6352, nvs09 -50.59 -> -48.99, all still below the -43.13 oracle), against 1 marginally looser (tanksize at 20 s, 1.253535 -> 1.253040, a 4e-4 relative move, both below the 1.26864 certified optimum) and 0 lost. Punctuality is neutral-to-better: mean wall/time_limit 0.447 -> 0.440 with the two large wins on the class the issue names (casctanks 1.23x -> 1.02x, beuster 1.13x -> 0.98x) and no pair newly over 1.25x.

One measured counter-example, recorded rather than averaged away: hda at an 8 s budget (not one of the panel’s two) goes the other way — 9.68 s (sd 0.25) -> 10.26 s (sd 0.10) over 3 interleaved reps per arm, 1.21x -> 1.28x, with a bit-identical bound. Root setup finishing 2.4 s earlier lets the search start two more nodes and the last one straddles the deadline: the pre-existing per-node overrun (#966), given more chances to fire, not a setup overrun.

Scope of that panel, stated rather than glossed: it is the in-repo corpus, which does contain casctanks (one of the four overrun instances the issue lists, reproducing both symptoms) but not the two the issue names. Those were run separately on the owner’s machine during the PR review and are the arm that closes it — sonet23v4 at tl=2 and watercontamination0202 at tl=30/tl=60, the instances behind #1152’s own two tests: 3 pass with the flag on its default and 3 xfail under DISCOPT_ROOT_SETUP_BUILD_DEADLINE=0, back to back at load 5.4. The OFF arm is what attributes the pass to this flag rather than to the machine, and doubles as an end-to-end check that the §5 opt-out is live. See docs/dev/1152-time-limit-root-setup-contract-2026-09-04.md.

node_round_budget: bool = False#

Make a per-node separated-relaxation ROUND honor its grant end-to-end (DISCOPT_NODE_ROUND_BUDGET, default off; §5 bound-changing; issue #966).

#928 made the warm pure-LP path honor its per-solve time_limit, and the residual budget overrun measurably moved OUT of the LP layer: a round’s grant clamps only the LP solves, while the round’s non-LP cost — the cold build_uniform_relaxation (~1.1–3.3 s on contvar) plus separation — is spent AFTER the admission check, unclamped. Measured (contvar @ 20 s, both arms, scratchpad/issue966_phase_probe.py): a node round granted 2.0 s runs 5.3–5.8 s, ~3.3 s of it the build; the budget-honoring LPs return sooner, the loop fits MORE such rounds, and the ON arm’s wall goes UP (issue #966’s 20 s-budget sign flip: ON−OFF +325.4/+68.5/+12.7 s over 3 reps).

When on, the spatial B&B node loops:

  • pass the round’s grant to solve_at_node as an absolute round_deadline, which (a) truncates the cold build’s constraint-row loop at the grant (the #694/#832 anytime-build mechanism — a prefix of rows is a valid weaker outer relaxation) and (b) anchors the node’s internal solve/separation deadline at the grant instead of restarting the clock after the build; and

  • run a round whose grant cannot cover the relaxer’s measured cold-build cost (an EMA over this solve’s builds) in yield mode instead of a full round: no per-node separation chain, and the cold build truncated at the grant, so the round banks the weaker-but-valid bound and the LP vertex it can afford rather than nothing — except in the ROOT batch, which always runs a full round (#928 rule 1). The root has no parent bound, so a weakened round there leaves the whole tree with no bound source; that is the bound=None collapse the first coupled graduation panel measured on contvar (7 → 287 nodes, nothing ever certified). The same rule already governs the root-relaxation fallback’s _fb_stop: a phase is optional tightening only once some valid bound is in hand.

Yield mode replaced an outright decline (the first cut of this flag, and the measured cause of the coupled panel’s bound ledger: casctanks 2.9098 → −56.5001, contvar’s bound lost outright). A skipped round banks no bound AND no LP point, and the point is what the spatial brancher and the primal heuristics run on: forcing every round to skip on nvs05 @ 20 s cost the bound (3.514 → 0.684), the incumbent (8.73 → 523.69) and the search itself (29 → 1 nodes, ending 16 s inside its own budget), while yielding the same rounds kept the incumbent, recovered half the lost bound (1.353) and branched 45 nodes (scratchpad/issue966_yield_vs_decline.py).

Sound by construction: build truncation and skipped separation only drop rows/cuts (weaker, never falsified — the _objective_bound_valid gate catches an un-bounded cost column), deadline-cut LP solves already bank the Neumaier-Shcherbina floor, and a round that IS cut short no longer returns nothing: it reports the rigorous box-interval objective floor of the (possibly truncated) relaxation it built — measured, a spent round grant lost the bound outright on 16 of 114 cells of the binding subset before that (#928, see _solve_at_node_impl). Should even that floor be unavailable, a yielded round leaves its node OPEN at -inf (floored at the proved parent bound) rather than fathomed-without-proof on the failure sentinel — see solver._yield_keeps_node_open. Default off pending the §5 corpus-wide differential panel (cert-clean AND net-positive); graduation is coupled to the #928 DISCOPT_LP_WARM_DEADLINE panel this flag exists to unblock.

hessian_compile_gate: bool = False#

Refuse to START a nonconvex node NLP whose first-time sparse-Hessian XLA compile cannot fit the solve’s remaining budget (DISCOPT_HESS_COMPILE_GATE, default off; §5 bound-changing; issue #966).

#966’s rare severe overruns (200–500 s past a 20 s budget, BOTH DISCOPT_LP_WARM_DEADLINE arms) were caught in flight with faulthandler.dump_traceback_later: the phase is the uninterruptible first-time XLA compile of the colored-HVP Lagrangian-Hessian kernel (jit_batch_hvp, sparse_hessian.py), fired from a node NLP — heatexch_gen3 @ 20 s: entered near the deadline, XLA’s own alarm reported the compile at 124 s, run wall 162.5 s. The F4 root-heuristic budget gate (_root_heur_nlp_entry_ok) exists for exactly this risk but the per-node NLP entries (root multistart on the no-relaxer class, strided node NLP, batch POUNCE) bypass it. An eager (jax.disable_jit) fallback was falsified as the fix (§4 entry experiment, scratchpad/issue966_eager_hessian_entry.py): eager evaluation costs 8–10 s PER CALL steady-state on this class — worse than useless inside a 20 s budget. A compile cannot be interrupted, so entry refusal is the whole mechanism (the F4 philosophy).

When on, the spatial node loops’ per-node NLP entries (the root multistart on the no-relaxer class — the caught severe mode — and the strided node NLP; _hess_compile_refuses in solver.py) plus the JAX-callback batch-POUNCE path decline to start a solve when the model is NONCONVEX and the evaluator’s (conservative) first-compile estimate exceeds the remaining budget. Convex solves are never gated — on the convex path the node NLP is the bound producer (rule 1: never skip the sole bound source) — which is why the check lives at the loop sites, where _model_is_convex is authoritative (the multistart chain does not thread convexity down). The native-AD POUNCE path never compiles XLA and is never gated. Bound-changing because a refused nonconvex node also skips the alphaBB/interval bounds nested behind the NLP on the serial path: the node stays OPEN at its inherited parent bound (weaker, never false) and the incumbent that NLP might have found is forgone. Residual exposure, recorded honestly: the compile cost is measured-unpredictable (1–186 s, R² ≈ 0 vs size — see estimate_hessian_compile_s), so an entry admitted with remaining budget above the risk floor can still overrun; this flag kills the observed late-entry severe modes, not the early-entry gamble the F4 floor deliberately accepts. Default off pending the §5 differential panel (graduation coupled to the #928/#966 panel).

singular_tangent: bool = False#

Recover the univariate-envelope tangent facet dropped at a vertical tangent (DISCOPT_SINGULAR_TANGENT, default off; §5 bound-changing; issue #1111).

uniform_relax._emit_1d places tangents at lo, the midpoint and hi. Where f is finite at an endpoint but f' diverges there, _tangent_row returned without emitting and the facet was silently lost, leaving the envelope one-sided on that side — reached by sqrt at t=0 and asin/acos at t=±1. When on, the facet is re-anchored at an interior ladder point (see _interior_tangent_point); the path only ever ADDS a row where none was emitted, so the flag-ON polytope is a subset of the flag-OFF polytope and the node LP bound can only improve or stay equal.

The §5 panel ran. Gate 1 (cert-clean) PASSES; gate 2 (net-positive) FAILS: the EAGER anchor is measured HARMFUL. On the only instance that terminates inside a deterministic node budget and moves at all, tspn08 goes 135 → 191 nodes (+41.5 %) to buy a bound gain in the 11th digit (290.56592504129753 → 290.56599569540646). mathopt5_6 is flat at 5 → 5 with a bit-identical bound, and kriging_peaks-full010 is flat to 14 digits. The subset property still holds — the ON bound is tighter at every node — but a different LP vertex changes the branching choice, so a tighter relaxation can still grow the tree. Soundness held throughout: no bound above its .solu reference, no certification regression, incumbents identical.

The root win also does not survive branching: kriging_peaks gains 13–40× at the root (full200 −74356.93 → −5596.46) yet after a few hundred nodes the arms agree to 14 digits. B&B was already recovering that bound cheaply.

DOUBLE RETRACTION (§11) — a retraction that was itself wrong. This docstring, the CHANGELOG and PR #1113 previously withdrew the tspn08 135 → 191 result and recorded the flag as “sound, and neutral”, on the strength of a panel showing 135 → 135 with bit-identical bounds. That withdrawal is itself withdrawn, and the +41 % is reinstated as measured above. The panel behind it never fired.

The instrument defect, measured not inferred: solve() is wrapped by a decorator (solver.py:6274-6280) that begins _set_tuning(kwargs.pop( "tuning", None)), and set_current() with None publishes a fresh, env-resolved SolverTuning(). A probe that installs a tuning with set_current(...) and then calls m.solve() without tuning= therefore has that context silently discarded at the solve boundary. Counting _interior_tangent_point calls inside a full solve of kriging_peaks-full010:

how the flag was set

calls

nodes

off (control)

0

311

set_current(...) around m.solve()

0

311

m.solve(tuning=...)

2671

313

DISCOPT_SINGULAR_TANGENT=1

2671

313

tuning= and the environment variable agree bit-for-bit; set_current was inert. So “both arms bit-identical” was not a neutrality result — it is the signature of a probe that never fired, and it explains exactly why the arms agreed to the last digit. That panel carried no drops counter, so nothing caught it (§6). Every root-relaxation measurement quoted below is unaffected: those drive build_uniform_relaxation directly, where set_current does reach (instrumented 10/10).

That delivery gap is now fixed (#1117) — the solve boundary calls enter_scope(), which inherits an ambient context instead of overwriting it, and the deep-recursion worker thread carries the caller’s contextvars context across. The table above is kept as the record of what the broken instrument produced, not as current behavior; set_current(...) around a plain m.solve() now fires the flag exactly like tuning=.

The reinstated number was re-measured on a corrected instrument — tuning= delivery, the ON arm asserted to have fired, a max_nodes budget with no time_limit (a wall limit changes the kernel path and makes the run non-reproducible; max_nodes alone is bit-reproducible over 3 repetitions per arm), both arms in one process on one binary — and reproduces at 135 → 191 on every run since.

Still retracted, and for reasons unrelated to the above: the first panel’s time-limited rows (kriging_peaks-full100 −1.144, tspn12 −0.520, …), taken at a 60 s wall limit under load 37–87 on 14 cores, fail the §9 load gate outright; and that panel’s printed tally better 10 worse 2 unchanged 1, whose classifier scored nodes_on < nodes_off as “better” — correct for a terminating run, backwards for a time-limited one, where fewer nodes means the arm did less work in the same budget.

#1111’s own motivating hypothesis is falsified: on the adiabatic LVC MECP model the facet is recovered but root bound and node count are unchanged (1 node either way).

Two further defects in #1111’s framing, both measured. (a) The issue expects the inverse-trig rows to matter most; across all 1610 MINLPLib .nl instances sqrt appears in 63 and asin/acos/acosh in zero, so on this corpus the feature is a sqrt feature. (b) The first panel was ~36 % diluted — 5 of its 14 instances (kriging_peaks-red*) contain no sqrt at all, so the flag could not act on them and those rows were pure noise. That dilution is why its numbers are retracted above; the panel quoted here is its successor, drawn by screening every corpus instance for an actually-dropped facet.

The mechanism, not the constant, is what is wrong. A fixed-offset reformulation (delta = width/8) was built and measured against this ladder and is strictly worse — about half the root-bound gain on every instance where the bound moves at all — and the anchor sweep behind singular_tangent_kappa shows the best static offset is problem-dependent by orders of magnitude, with both ends of the range degenerate. A static geometric anchor cannot be right for every box. What the measurements point at instead is placing the tangent where the LP binds rather than at a geometric offset — which is what MccormickLPRelaxer._separate_convex’s Kelley loop already does for composite convex/concave lifts (concave: d <= g(x0) + grad g(x0)·(x - x0) at the LP point x0). The soundness argument carries over unchanged — every tangent of a concave f is a global overestimator — and lazy separation also stops the solver paying for an eager extra row at every node whether or not that node’s LP is near the singular endpoint, which is precisely the cost the tspn08 regression above is charging.

That successor was built and measured — #1115, and it is the default placement. See singular_tangent_lazy. Eager anchoring is retained only as the A/B control for that measurement.

Final disposition (#1115). The lazy form was not falsified, so the #581 removal precedent does not apply: it is sound, it never loses the bound on the corpus, and it buys a real bound gain at an identical node count on kriging_peaks. It fails gate 2 only because it costs +25.6 %/+54.2 % on the instances it does not help. This flag therefore stays default-OFF and stays in the tree; what is missing is a trigger keyed on whether the facet binds often enough to pay for its rows, not a better mechanism. Flag-OFF is byte-identical to the pre-#1111 relaxation.

singular_tangent_lazy: bool = True#

Place the recovered vertical-tangent facet at the LP point (#1115).

Consulted only when singular_tangent is on; it selects where the recovered tangent goes, never whether the facet is recovered.

True (the default) defers the facet to MccormickLPRelaxer._separate_singular_tangent, which adds the supporting tangent at the current LP point and only when that point violates it. False restores #1111’s eager behaviour: one tangent per node at a fixed geometric ladder anchor near the endpoint, whether or not it binds.

Where the LP vertex sits on the singularity — the case the facet exists for, and where no finite-slope tangent is available — the touch point falls back to #1111’s conditioning-capped ladder anchor while the violation test still decides whether the row goes in. Without that fallback the separator degrades to a no-op precisely where it is needed: on min 2x - sqrt(x) over [0,4] the LP vertex lands exactly at t=0.

Measured. The eager form is harmful — tspn08 135 → 191 nodes (+41.5 %) for a bound gain in the 11th digit — which is what motivated #1115, and it is retained only as the A/B control. Lazy placement removes that regression (tspn08 back to 135 nodes) and, on isolated atoms, does what it was designed to do: min 2x - sqrt(x) over [0,4] goes −0.7071 → −0.12503 against a true optimum of −0.125, where the eager anchor reaches only −0.6557.

On the corpus lazy never loses the bound, and that is not enough. Three-arm panel (off / eager / lazy), 300-node budget with no wall limit, each arm’s firing asserted, 12 scorable instances: eager vs off: BETTER 1, WORSE 2, flat 9; lazy vs off: BETTER 3, flat 9, worse 0. Soundness checked on every arm of every run against minlplib.solu — 0 violations, including per-repetition asserts in the reproducibility and timing panels. The lazy gains are kriging_peaks-full050 (−142.657 → −139.918) and full100 (−348.054 → −342.588), ~1.6–1.9 % of gap, plus a marginal tspn15; each occurs at an identical node count and identical incumbent, i.e. a better bound for the same tree. Eager’s one BETTER cell (tspn15, +0.002) against two real losses leaves it net-negative.

The timing panel decides it, and it decides against graduation. Interleaved off/lazy, 3 repetitions, per-arm and pooled standard deviations, load gate recorded per instance (2.5–5.2 on 14 cores), both arms verified to explore identical trees:

eq6_1                 off  10.82+-0.12s   lazy  13.59+-0.09s   +25.6%   0 gain
maxmin                off  33.52+-0.13s   lazy  51.68+-0.04s   +54.2%   0 gain
kriging_peaks-full050 off  42.21+-0.21s   lazy  44.09+-0.12s    +4.5%  +1.9%
kriging_peaks-full100 off 101.21+-0.21s   lazy 105.22+-0.22s    +4.0%  +1.6%

Every delta is 12–200 pooled sd; none is noise. The shape disqualifies it: lazy is cheap where it helps and expensive where it does not — the two instances charged +25.6 % and +54.2 % are exactly the two that gain nothing, because they draw the most rows. Default-ON would pay the maxmin bill corpus-wide to collect the full050 gain occasionally. Gate 1 (cert-clean) passes; gate 2 (net-positive) fails; the flag stays default-OFF. Same disposition as cut_inherit, reached by a different route — that one was neutral-or-harmful, this one is helpful on a narrow class and unaffordable off it.

Corrections to earlier readings recorded here (§11):

  • kriging_peaks-full200 was reported as a third gain (−746.566 → −734.495, +1.6 %) and is withdrawn. Over 3 reps on a quiet machine it is nondeterministic in both arms: off gives nodes {301, 303} with two distinct bounds and zero separated rows; lazy gives {301, 303} with rows {32451, 32472, 32583}. Two single runs of a nondeterministic instance are not a comparison. Pre-existing at that size, unrelated to this flag, filed separately.

  • An intermediate claim that lazy caused that nondeterminism is also withdrawn: it rested on two reps in which off agreed, and the third falsified it.

  • The quoted bounds are load-dependent to ~3 significant figures. Quiet, full100 gives −350.768 → −345.634 over 12 498 rows; under the original panel’s contention, −348.054 → −342.588 over 13 214. Separation is wall-bounded (every _separate_* breaks on a shared _deadline; the node LP re-solve takes time_limit=_remaining()), so row counts and the bounds they produce shift with machine load. The direction reproduces; the digits do not.

Remaining gap:

  • A coverage hole eager does not have. The separation chain is gated on if separate: (mccormick_lp.py), forced off on yield rounds and pool-free re-solves. On all three elec* instances the lazy arm registered thousands of specs (19 236 on elec100) and the separator ran zero times, while eager fired at build time throughout. Lazy is not a coverage superset of eager: it trades “emit a geometric guess wherever a relaxation is built” for “emit an exact tangent only where a separation round runs”.

Where the gains come from: both sit in kriging_peaks, the family with the largest root gain (13–40×). On full010, 12 separator invocations move the node LP objective 7 times but only by ~1e-4 against a bound of −5.69 — live, and far too small to change a branching decision. So the #1111 finding “the root win does not survive branching” is scoped to the eager anchor: where the dropped facet dominates the root relaxation, lazy placement does retain part of it.

Why this stays default-OFF rather than becoming conditional (#1119, closed falsified 2026-08-23). The successor question was whether a hit-rate gate — keep the rows that bind in the LP optimal basis, drop the ones that do not — could remove the eq6_1/maxmin overhead while retaining the kriging_peaks gain. It cannot, and the measurement runs the wrong way: over 200 nodes the two instances that PAY bind at 1.0000 and 0.9580, the two that GAIN bind at 0.9173 and 0.8509, and eq6_1 has zero non-binding rows — so the gate drops 0 of its 22 874 rows and saves none of the +25.6 %, while discarding 14.9 % of the rows on the instance the feature exists for. Binding is near-tautological here: a row is emitted because it is violated at the current LP point, so the re-solve that follows lands on it. Full record (including the screen showing only 8 of 96 candidate instances emit any row at all) in docs/dev/performance-plan.md §17; the instrument is MccormickLPRelaxer.singular_tangent_stats.

singular_tangent_kappa: float = 100.0#

Cap on |f'(t0)| for singular_tangent, as a multiple of the box’s own slope scale (DISCOPT_SINGULAR_TANGENT_KAPPA, default 100).

A measurement knob for the §5 panel, not a user-facing tuning parameter. Only consulted when singular_tangent is on.

The cap is right; the rationale first written here was not, and is retracted (§11). The original justification was that the outward-rounding guard (outward_rounding.envelope_1d_slack) grows linearly with |slope|, so an uncapped near-vertical tangent buys tightness with rounding slack. The linearity is real (outward_rounding.py returns outward_slack(d*tmag + ...)) but the magnitude is not: at kappa=100 the added slack is ~1e-14 against a cut depth of ~0.35 — six orders of magnitude too small to be the reason for anything.

The cap’s actual justification is a measured degeneracy at the uncapped end. Sweeping the anchor offset delta over eight orders on the three kriging_peaks-full instances that move the root bound at all, the root LP bound tightens as the anchor approaches the singular endpoint, peaks near delta ~ 1e-5, and then collapses back to the flag-OFF value as delta shrinks further (full020: OFF −1113.72; gain +552 at 1/8, +1087 at 1.2e-4, +1098 at 1e-5, +1097 at 1e-7, +1039 at 1e-9, +0 at 1e-12 — same shape on full010 and full050). Past the peak the tangent is numerically degenerate and the emitted row stops constraining anything. Without a cap the ladder takes its smallest rung, delta = 0.5*8**-20 ~ 5.5e-19, which is inside the collapse: the feature would emit a row on every singular atom and buy nothing. That is what the cap is for, and it reproduces on an isolated atom too — min 3x - sqrt(x) over [0,4], whose LP optimum sits at the singular end, gains +4.9e-09 at kappa=1e12 against +0.083 at kappa=100.

``kappa=100`` is not measured-optimal, and the optimum is problem-dependent. On that same isolated atom the best rung is near kappa ~ 10 (gain +0.619, 7x the default’s +0.083), i.e. an anchor further from the endpoint; on kriging_peaks the peak is at a smaller delta than the default selects. The two disagree by orders of magnitude, so no single constant is right for both — which is the deeper reason a static geometric rule is the wrong mechanism here (see singular_tangent). Treat 100 as a serviceable midpoint that is on the stable side of the collapse, not as a tuned value. (The isolated-atom figure is a synthetic proxy and is quoted only for the shape of the curve; per the #727 RLT lesson the kriging_peaks numbers are the ones drawn from the real corpus.)

This also falsifies a mean-envelope-slack argument considered as a replacement for the ladder (a fixed delta = width/8, chosen because it minimises mean envelope slack over the box, measured 3.31x better than the near-corner anchor on that metric). Mean slack over the box does not predict bound tightness: on the same instances the near-corner anchor gives ~**2x** the root-bound gain of width/8 (full010 +2975 vs +1513, full200 +68769 vs +34967). Only slack where the relaxation binds matters, and on this family the LP optimum sits at the singular endpoint. The fixed-offset reformulation was measured, found strictly worse, and discarded.

replace(**changes) → SolverTuning#

Return a copy with changes applied (validated).

discopt.solver_tuning.current() → SolverTuning#

The active SolverTuning (a fresh env-resolved one outside a solve).

discopt.solver_tuning.set_current(tuning: SolverTuning | None)#

Publish tuning (or a fresh env-resolved one) as active; returns the token.

discopt.solver_tuning.enter_scope(tuning: SolverTuning | None)#

Publish tuning for a solve scope, inheriting the active context when tuning is None; returns the token to hand to reset_current().

This is the difference between “no override was requested” and “override with env defaults”. set_current() cannot express the former — None there means a fresh env-resolved instance — so the solve boundary used to overwrite whatever a caller had installed with set_current(), discarding it in silence (issue #1117). The precedence here is explicit tuning= kwarg > ambient context > environment defaults, which is what a caller reading

token = solver_tuning.set_current(tuning)
try:
    m.solve()
finally:
    solver_tuning.reset_current(token)

already expects. Nested solves inherit the outer scope for the same reason.

discopt.solver_tuning.reset_current(token) → None#
discopt.solver_tuning.ROLE2_SATURATION_S = 150.0#
discopt.solver_tuning.saturate_role2(seconds: float, frac: float) → float#

Cap a role-2 sub-budget carved as frac of time_limit (#1153).

seconds is what the site computed; frac is the fraction of time_limit it was carved from. The cap is the value that carve reaches at a ROLE2_SATURATION_S role-1 budget, so the grant saturates instead of tracking the caller’s budget upward forever.

Why saturation and not merely “a fraction”. A fraction is monotone in time_limit but not harmless: a root stage whose grant keeps growing keeps separating cuts, and every subsequent node LP carries them, so the per-node cost rises with the caller’s budget and the tree the remaining budget can cover shrinks. #1153 measured the end of that: nvs19 at 30 s reached -1098.2 over 38 403 nodes and at 60 s reached -1001.2 over 7 619 — a worse answer from twice the budget. Saturating the carve makes every second granted beyond ROLE2_SATURATION_S go to the search.

Default-off behind SolverTuning.budget_saturation (bound-changing, CLAUDE.md §5); with the flag off this returns seconds unchanged, so the legacy path is byte-identical.

Sound either way: every site this guards is optional tightening, and a truncated tightening pass yields a weaker relaxation, never an invalid one.

discopt.solver_tuning.HEURISTIC_ENTRY_SHARE = 0.25#
discopt.solver_tuning.heuristic_entry_share() → float#

The budget share a finder-role heuristic must fit in (#1153).

Returns 1.0 — the legacy rule, byte-identical — unless SolverTuning.heuristic_entry_share is on.