Solver Internals: BARON-Parity Features#

This notebook documents the algorithmic features that close the gap between discopt and commercial global solvers like BARON [Tawarmalani and Sahinidis, 2005]. These are internal solver improvements that users benefit from automatically, but understanding them helps with tuning.

Topics covered:

  1. Convexity detection — automatic classification of expressions, constraints, and models

  2. Adaptive solver dispatch — skipping unnecessary relaxation overhead for convex problems

  3. Per-constraint OA cuts — outer approximation for individually convex constraints

  4. Reliability branching — pseudocost-guided variable selection in Branch & Bound

  5. OBBT with incumbent cutoff — tighter bounds using the best known solution

  6. FBBT with incumbent cutoff — fast constraint propagation with objective cutoff

  7. Specialized envelopes — tight relaxations for trig, signomial, and additional functions

import os

os.environ["JAX_PLATFORMS"] = "cpu"
os.environ["JAX_ENABLE_X64"] = "1"


import discopt.modeling as dm
from discopt._jax.convexity import classify_constraint, classify_expr, classify_model

1. Convexity Detection#

discopt includes a convexity detector that walks the expression DAG and classifies each subexpression using standard composition rules from convex analysis [Boyd and Vandenberghe, 2004]:

Rule

Result

constant, variable, parameter

AFFINE

convex + convex

CONVEX

concave + concave

CONCAVE

convex + concave

UNKNOWN

positive_const * convex

CONVEX

negative_const * convex

CONCAVE

exp(convex)

CONVEX (exp is convex & nondecreasing)

log(concave)

CONCAVE (log is concave & nondecreasing)

x^2 (affine base)

CONVEX

x^(2k) (even power, affine base)

CONVEX

x^p with p > 1, x >= 0

CONVEX

x^p with 0 < p < 1, x >= 0

CONCAVE

x * y (bilinear)

UNKNOWN

Results are cached on expression IDs for efficiency.

# Build some expressions and classify them
m = dm.Model("convexity_demo")
x = m.continuous("x", lb=0, ub=5)
y = m.continuous("y", lb=0, ub=5)

expressions = {
    "x": x,
    "x + y": x + y,
    "x^2": x**2,
    "x^2 + y^2": x**2 + y**2,
    "exp(x)": dm.exp(x),
    "log(x)": dm.log(x),
    "sqrt(x)": dm.sqrt(x),
    "exp(x^2)": dm.exp(x**2),
    "log(x + y)": dm.log(x + y),
    "x * y": x * y,
    "-x^2": -(x**2),
    "x^3 (nonneg)": x**3,
}

print(f"{'Expression':<20s} {'Curvature':<12s}")
print("-" * 32)
for name, expr in expressions.items():
    curv = classify_expr(expr, model=m)
    print(f"{name:<20s} {curv.value:<12s}")
Expression           Curvature   
--------------------------------
x                    affine      
x + y                affine      
x^2                  convex      
x^2 + y^2            convex      
exp(x)               convex      
log(x)               unknown     
sqrt(x)              concave     
exp(x^2)             convex      
log(x + y)           unknown     
x * y                unknown     
-x^2                 concave     
x^3 (nonneg)         convex      

Constraint Convexity#

A constraint is convex when it defines a convex feasible set:

  • g(x) <= 0 is convex when g is convex

  • g(x) >= 0 is convex when g is concave (equivalently, -g is convex)

  • g(x) == 0 is convex only when g is affine

from discopt.modeling.core import Constraint

constraints = [
    ("x^2 + y^2 <= 10", Constraint(body=x**2 + y**2, sense="<=", rhs=10)),
    ("log(x) >= 1", Constraint(body=dm.log(x), sense=">=", rhs=1)),
    ("x + y == 3", Constraint(body=x + y, sense="==", rhs=3)),
    ("x * y <= 5", Constraint(body=x * y, sense="<=", rhs=5)),
    ("x^2 == 4", Constraint(body=x**2, sense="==", rhs=4)),
]

print(f"{'Constraint':<20s} {'Convex?':<8s}")
print("-" * 28)
for name, c in constraints:
    is_cvx = classify_constraint(c, model=m)
    print(f"{name:<20s} {str(is_cvx):<8s}")
Constraint           Convex? 
----------------------------
x^2 + y^2 <= 10      True    
log(x) >= 1          False   
x + y == 3           True    
x * y <= 5           False   
x^2 == 4             False   

Model-Level Classification#

classify_model() checks whether the entire optimization problem is convex — meaning the objective is convex (for minimization) and all constraints are convex.

# A convex QP
m1 = dm.Model("convex_qp")
x = m1.continuous("x", lb=-5, ub=5)
y = m1.continuous("y", lb=-5, ub=5)
m1.minimize(x**2 + y**2)
m1.subject_to(x + y >= 1)

is_cvx, mask = classify_model(m1)
print(f"Convex QP: model_is_convex={is_cvx}, constraint_mask={mask}")

# A nonconvex problem (bilinear constraint)
m2 = dm.Model("nonconvex")
x = m2.continuous("x", lb=0, ub=5)
y = m2.continuous("y", lb=0, ub=5)
m2.minimize(x + y)
m2.subject_to(x * y >= 1)  # nonconvex
m2.subject_to(x + y <= 8)  # convex (affine)

is_cvx, mask = classify_model(m2)
print(f"Nonconvex:  model_is_convex={is_cvx}, constraint_mask={mask}")
print("  -> constraint 0 (x*y >= 1) is nonconvex, constraint 1 (x+y <= 8) is convex")
Convex QP: model_is_convex=True, constraint_mask=[True]
Nonconvex:  model_is_convex=False, constraint_mask=[False, True]
  -> constraint 0 (x*y >= 1) is nonconvex, constraint 1 (x+y <= 8) is convex

2. Adaptive Solver Dispatch#

When the convexity detector classifies a model as convex, the solver automatically:

  1. Skips alphaBB convexification — no need to add convexifying terms since the problem is already convex

  2. Skips McCormick relaxation overhead — the NLP solution is already a valid lower bound

  3. Treats NLP solutions as global optima — no need for spatial Branch & Bound for the continuous relaxation

This can provide significant speedups on convex problems (QPs, SOCPs, geometric programs) that happen to be formulated through the MINLP interface.

import logging
import time

# Enable logging to see the convexity detection message
logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
logger = logging.getLogger("discopt.solver")
logger.setLevel(logging.INFO)

# Solve a convex MIQP — the solver detects convexity and skips relaxation overhead
m = dm.Model("convex_miqp")
x = m.continuous("x", lb=0, ub=10)
y = m.continuous("y", lb=0, ub=10)
z = m.binary("z")

m.minimize(x**2 + y**2 + z)
m.subject_to(x + y >= 2)
m.subject_to(x <= 8 * z)

t0 = time.perf_counter()
result = m.solve()
elapsed = time.perf_counter() - t0

print(f"\nStatus: {result.status}")
print(f"Objective: {result.objective:.6f}")
print(f"Time: {elapsed:.3f}s")
print(f"Nodes: {result.node_count}")

# Reset logging
logger.setLevel(logging.WARNING)
discopt.solver: Using discopt IPM (pure-JAX interior point method)
Status: optimal
Objective: 3.000000
Time: 0.620s
Nodes: 3

3. Per-Constraint Outer Approximation#

Outer approximation (OA) cuts [Duran and Grossmann, 1986, Fletcher and Leyffer, 1994] are tangent hyperplanes that provide valid linear underestimators of convex constraints. In a nonconvex problem, some constraints may still be individually convex — OA cuts are valid for those constraints even though the overall problem is nonconvex.

discopt uses the convexity detector to identify which constraints are convex, then generates OA cuts only for those. This replaces the earlier approach that only generated OA for affine (linear) constraints.

# Mixed convex/nonconvex problem
m = dm.Model("per_constraint_oa")
x = m.continuous("x", lb=0.1, ub=5)
y = m.continuous("y", lb=0.1, ub=5)
z = m.binary("z")

m.minimize(x**2 + y + z)
m.subject_to(x**2 + y**2 <= 10)  # convex constraint -> OA valid
m.subject_to(x * y >= 1)  # nonconvex constraint -> no OA
m.subject_to(x + y <= 6 * z)  # affine -> OA valid

is_cvx, mask = classify_model(m)
print(f"Model is convex: {is_cvx}")
print(f"Per-constraint convexity: {mask}")
print("  constraint 0 (x^2+y^2 <= 10): convex =", mask[0])
print("  constraint 1 (x*y >= 1):       convex =", mask[1])
print("  constraint 2 (x+y <= 6z):      convex =", mask[2])
print()

# Solve with cutting planes — OA cuts generated for constraints 0 and 2
result = m.solve(cutting_planes=True, max_nodes=200)
print(f"Status: {result.status}, Objective: {result.objective:.4f}")
Model is convex: False
Per-constraint convexity: [True, False, True]
  constraint 0 (x^2+y^2 <= 10): convex = True
  constraint 1 (x*y >= 1):       convex = False
  constraint 2 (x+y <= 6z):      convex = True
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
 Ipopt is released as open source code under the Eclipse Public License (EPL).
         For more information visit https://github.com/coin-or/Ipopt
******************************************************************************
Status: optimal, Objective: 2.8899

4. Reliability Branching#

In Branch & Bound, variable selection significantly impacts performance. discopt implements reliability branching [Achterberg et al., 2005], which combines:

  • Pseudocost branching: After each branch, record the objective improvement per unit of variable change. Maintain running averages (pseudocosts) per variable. Select the variable with the highest product score.

  • Strong branching: For variables with fewer than \(\eta_{\text{rel}}\) (default 8) observations, solve LP relaxations for both child nodes to get reliable bound estimates.

The hybrid approach starts with strong branching (expensive but informative) and transitions to pseudocost branching (cheap and accurate) as the tree matures.

How it works in discopt#

  1. The Rust TreeManager tracks pseudocosts and observation counts per integer variable

  2. score_candidates(solution) returns fractional variables with their pseudocost scores and observation counts

  3. For unreliable variables (obs < threshold), Python-side strong branching solves LP relaxations via HiGHS

  4. Branch hints are passed back to Rust via set_branch_hints()

# Compare branching policies on a larger MINLP
m = dm.Model("branching_comparison")
xs = [m.continuous(f"x{i}", lb=0, ub=5) for i in range(4)]
ys = [m.binary(f"y{i}") for i in range(4)]

# Quadratic objective with indicator constraints
m.minimize(sum((x - 1.5) ** 2 for x in xs) + sum(2 * y for y in ys))
for i in range(4):
    m.subject_to(xs[i] <= 4 * ys[i])
m.subject_to(sum(xs) >= 3)
m.subject_to(sum(ys) <= 3)

# Most-fractional branching (baseline)
r1 = m.solve(branching_policy="fractional", max_nodes=500)
print(f"Fractional branching: obj={r1.objective:.4f}, nodes={r1.node_count}")

# Reliability branching (pseudocost + strong branching)
r2 = m.solve(branching_policy="reliability", max_nodes=500)
print(f"Reliability branching: obj={r2.objective:.4f}, nodes={r2.node_count}")
Fractional branching: obj=8.2500, nodes=27
Reliability branching: obj=8.2500, nodes=27

5. OBBT with Incumbent Cutoff#

Optimality-Based Bound Tightening (OBBT) [Gleixner et al., 2017] solves LP relaxations to find the tightest possible bounds on each variable:

\[\underline{x}_i = \min_{x} x_i \quad \text{s.t.} \quad Ax \leq b, \quad l \leq x \leq u\]
\[\overline{x}_i = \max_{x} x_i \quad \text{s.t.} \quad Ax \leq b, \quad l \leq x \leq u\]

When an incumbent solution with objective \(z^*\) is known, we can add the constraint \(c^T x \leq z^*\) to the OBBT LPs. This often dramatically tightens bounds because many variable values become infeasible when we require the objective to be at least as good as the incumbent.

discopt runs OBBT periodically during Branch & Bound whenever a new incumbent is found.

from discopt._jax.obbt import run_obbt

# Build a model with linear constraints
m = dm.Model("obbt_demo")
x = m.continuous("x", lb=0, ub=10)
y = m.continuous("y", lb=0, ub=10)

m.minimize(x + 2 * y)
m.subject_to(x + y >= 3)
m.subject_to(2 * x + y <= 12)

# Run OBBT without cutoff
result_no_cut = run_obbt(m)
print("OBBT without incumbent cutoff:")
print(f"  x bounds: [{result_no_cut.tightened_lb[0]:.4f}, {result_no_cut.tightened_ub[0]:.4f}]")
print(f"  y bounds: [{result_no_cut.tightened_lb[1]:.4f}, {result_no_cut.tightened_ub[1]:.4f}]")
print(f"  LPs solved: {result_no_cut.n_lp_solves}, bounds tightened: {result_no_cut.n_tightened}")
print()

# Run OBBT with incumbent cutoff z* = 8 (x + 2y <= 8)
result_cut = run_obbt(m, incumbent_cutoff=8.0)
print("OBBT with incumbent cutoff z* = 8:")
print(f"  x bounds: [{result_cut.tightened_lb[0]:.4f}, {result_cut.tightened_ub[0]:.4f}]")
print(f"  y bounds: [{result_cut.tightened_lb[1]:.4f}, {result_cut.tightened_ub[1]:.4f}]")
print(f"  LPs solved: {result_cut.n_lp_solves}, bounds tightened: {result_cut.n_tightened}")
OBBT without incumbent cutoff:
  x bounds: [0.0000, 6.0000]
  y bounds: [0.0000, 10.0000]
  LPs solved: 4, bounds tightened: 1

OBBT with incumbent cutoff z* = 8:
  x bounds: [0.0000, 6.0000]
  y bounds: [0.0000, 4.0000]
  LPs solved: 4, bounds tightened: 2

6. FBBT with Incumbent Cutoff#

Feasibility-Based Bound Tightening (FBBT) [Belotti et al., 2009] propagates bound information through the expression DAG without solving optimization problems, making it much cheaper than OBBT. When an incumbent solution with objective \(z^*\) is known, the cutoff constraint \(f(x) \leq z^*\) is added to the propagation, often triggering cascading domain reductions.

In discopt, FBBT is implemented in Rust (fbbt_with_cutoff) and exposed to Python via PyO3 bindings. It runs at every B&B node before the NLP relaxation solve, while OBBT is reserved for periodic invocation when a new incumbent is found. The combination provides complementary bound tightening: FBBT is fast but conservative; OBBT is expensive but finds the tightest LP-based bounds [Ryoo and Sahinidis, 1996].

import numpy as np
from discopt._rust import model_to_repr

# Build a simple model and run FBBT with and without cutoff
m = dm.Model("fbbt_demo")
x = m.continuous("x", lb=0, ub=100)
y = m.continuous("y", lb=0, ub=100)
m.minimize(x)
m.subject_to(x + y <= 10)

repr_ = model_to_repr(m)

# Without cutoff: FBBT propagates constraint x+y <= 10
lbs0, ubs0 = repr_.fbbt(max_iter=10, tol=1e-8)
lbs0, ubs0 = np.asarray(lbs0), np.asarray(ubs0)
print("FBBT without cutoff:")
print(f"  x: [0, {ubs0[0]:.0f}]  (was [0, 100])")
print(f"  y: [0, {ubs0[1]:.0f}]  (was [0, 100])")

# With cutoff z* = 7: adds x <= 7 to propagation
lbs1, ubs1 = repr_.fbbt_with_cutoff(max_iter=10, tol=1e-8, incumbent_bound=7.0)
lbs1, ubs1 = np.asarray(lbs1), np.asarray(ubs1)
print("\nFBBT with incumbent cutoff z* = 7:")
print(f"  x: [0, {ubs1[0]:.0f}]  (was [0, 100])")
print(f"  y: [0, {ubs1[1]:.0f}]  (was [0, 100])")
print(f"\nCutoff tightened x upper bound: {ubs0[0]:.0f} -> {ubs1[0]:.0f}")
FBBT without cutoff:
  x: [0, 10]  (was [0, 100])
  y: [0, 10]  (was [0, 100])

FBBT with incumbent cutoff z* = 7:
  x: [0, 7]  (was [0, 100])
  y: [0, 10]  (was [0, 100])

Cutoff tightened x upper bound: 10 -> 7

7. Specialized Envelopes#

Standard McCormick relaxations [McCormick, 1976] compose relaxations through the expression tree. This can be loose because outer functions see the relaxation of inner functions, not the original function.

Specialized envelopes compute exact convex/concave envelopes directly for common operations, avoiding the composition gap.

Available envelopes#

Function

Envelope

Notes

x^p (integer p)

relax_power_int

Even p: always convex. Odd p, x>=0: convex. Mixed: tangent construction

exp(x)

relax_exp_tight

Convex envelope = exp(x), concave envelope = secant line

log(x)

relax_log_tight

Concave envelope = log(x), convex envelope = secant line

sin(x)

relax_sin_tight

Regime-aware: concave/convex/mixed [Locatelli and Schoen, 2014]

cos(x)

relax_cos_tight

Via cos(x) = sin(x + π/2)

∏xᵢ^aᵢ

relax_signomial_multi

Log-space decomposition [Maranas and Floudas, 1997]

asinh(x)

relax_asinh

Concave on [0,∞), convex on (-∞,0]

acosh(x)

relax_acosh

Concave on [1,∞)

atanh(x)

relax_atanh

Convex on [0,1), concave on (-1,0]

erf(x)

relax_erf

Convex on (-∞,0], concave on [0,∞)

log(1+x)

relax_log1p

Concave; numerically stable near x=0

1/x

relax_reciprocal

Convex on (0,∞), concave on (-∞,0)

exp(x)*y

relax_exp_bilinear

Decomposed bilinear-exponential

log(x+y)

relax_log_sum

Concave envelope using log concavity

x*y*z

relax_trilinear

Trilinear term relaxation

The relaxation compiler automatically selects specialized envelopes when the expression matches a known pattern (e.g., x**3 for a variable with known bounds, sin(x) with a plain variable argument).

import jax.numpy as jnp
from discopt._jax.envelopes import relax_exp_tight, relax_power_int

# Demonstrate power envelope for x^3 on [0.5, 3]
lb, ub = 0.5, 3.0
test_points = jnp.linspace(lb, ub, 7)

print("relax_power_int(x, lb=0.5, ub=3.0, p=3):")
print(f"{'x':>6s} {'x^3':>10s} {'cv (lower)':>12s} {'cc (upper)':>12s} {'gap':>8s}")
print("-" * 50)
for x_val in test_points:
    cv, cc = relax_power_int(x_val, lb, ub, 3)
    actual = x_val**3
    print(
        f"{float(x_val):6.2f} {float(actual):10.4f} {float(cv):12.4f} {float(cc):12.4f}"
        f" {float(cc - cv):8.4f}"
    )

print()
print("relax_exp_tight(x, lb=0, ub=2):")
lb_e, ub_e = 0.0, 2.0
test_exp = jnp.linspace(lb_e, ub_e, 5)
print(f"{'x':>6s} {'exp(x)':>10s} {'cv':>10s} {'cc':>10s}")
print("-" * 38)
for x_val in test_exp:
    cv, cc = relax_exp_tight(x_val, lb_e, ub_e)
    print(f"{float(x_val):6.2f} {float(jnp.exp(x_val)):10.4f} {float(cv):10.4f} {float(cc):10.4f}")
relax_power_int(x, lb=0.5, ub=3.0, p=3):
     x        x^3   cv (lower)   cc (upper)      gap
--------------------------------------------------
  0.50     0.1250       0.1250       0.1250   0.0000
  0.92     0.7703       0.7703       4.6042   3.8339
  1.33     2.3704       2.3704       9.0833   6.7130
  1.75     5.3594       5.3594      13.5625   8.2031
  2.17    10.1713      10.1713      18.0417   7.8704
  2.58    17.2402      17.2402      22.5208   5.2807
  3.00    27.0000      27.0000      27.0000   0.0000

relax_exp_tight(x, lb=0, ub=2):
     x     exp(x)         cv         cc
--------------------------------------
  0.00     1.0000     1.0000     1.0000
  0.50     1.6487     1.6487     2.5973
  1.00     2.7183     2.7183     4.1945
  1.50     4.4817     4.4817     5.7918
  2.00     7.3891     7.3891     7.3891

Tight trigonometric envelopes#

For sin(x) on a bounded interval, the relaxation quality depends on the curvature regime [Locatelli and Schoen, 2014]. relax_sin_tight identifies whether the interval lies in the concave, convex, or mixed regime and selects the appropriate secant/function construction. relax_cos_tight delegates via cos(x) = sin(x + π/2).

import jax
import jax.numpy as jnp
from discopt._jax.envelopes import relax_sin_tight
from discopt._jax.mccormick import relax_sin

# Compare tight vs standard McCormick for sin on a concave regime
lb, ub = 0.3, 2.5
xs = jnp.linspace(lb, ub, 50)
sin_vals = jnp.sin(xs)

# Tight envelope (regime-aware)
tight_cvs, tight_ccs = jax.vmap(lambda x: relax_sin_tight(x, lb, ub))(xs)

# Standard McCormick (compositional)
std_cvs, std_ccs = jax.vmap(lambda x: relax_sin(x, lb, ub))(xs)

# Compare gaps
tight_gap = float(jnp.mean(tight_ccs - tight_cvs))
std_gap = float(jnp.mean(std_ccs - std_cvs))
print(f"sin(x) on [{lb}, {ub}] (concave regime):")
print(f"  Standard McCormick avg gap: {std_gap:.4f}")
print(f"  Tight envelope avg gap:     {tight_gap:.4f}")
print(f"  Gap reduction:              {100 * (1 - tight_gap / std_gap):.1f}%")
sin(x) on [0.3, 2.5] (concave regime):
  Standard McCormick avg gap: 0.3442
  Tight envelope avg gap:     0.3442
  Gap reduction:              0.0%

Multivariate signomial envelopes#

For signomial terms \(\prod x_i^{a_i}\) with positive variables, relax_signomial_multi decomposes the product via logarithms [Maranas and Floudas, 1997]: \(\prod x_i^{a_i} = \exp(\sum a_i \log x_i)\). The compiler auto-detects products of Variable**Constant terms and routes to this envelope when all lower bounds are positive.

from discopt._jax.envelopes import relax_signomial_multi

# Relaxation of x^0.5 * y^1.5 on [1,4] x [1,5]
xs = jnp.array([2.0, 3.0])
lbs = jnp.array([1.0, 1.0])
ubs = jnp.array([4.0, 5.0])
exponents = jnp.array([0.5, 1.5])

cv, cc = relax_signomial_multi(xs, lbs, ubs, exponents)
true_val = float(2.0**0.5 * 3.0**1.5)
print("x^0.5 * y^1.5 at (2, 3):")
print(f"  True value: {true_val:.4f}")
print(f"  cv (lower):  {float(cv):.4f}")
print(f"  cc (upper):  {float(cc):.4f}")
print(f"  Gap:         {float(cc - cv):.4f}")
x^0.5 * y^1.5 at (2, 3):
  True value: 7.3485
  cv (lower):  4.2128
  cc (upper):  7.3485
  Gap:         3.1357

Additional univariate envelopes#

The envelope library includes relaxations for asinh, acosh, atanh, erf, log1p, and 1/x, each exploiting known curvature properties. For each, the convex regime uses the function as underestimator and secant as overestimator; the concave regime reverses these. Mixed-sign domains use conservative min/max bounds.

from discopt._jax.envelopes import (
    relax_acosh,
    relax_asinh,
    relax_atanh,
    relax_erf,
    relax_log1p,
    relax_reciprocal,
)

# Demo each envelope at a sample point
demos = [
    ("asinh", relax_asinh, jnp.arcsinh, 0.5, 3.0, 1.5),
    ("acosh", relax_acosh, jnp.arccosh, 1.5, 4.0, 2.5),
    ("atanh", relax_atanh, jnp.arctanh, 0.1, 0.8, 0.4),
    ("erf", relax_erf, jax.scipy.special.erf, -2.0, 2.0, 0.5),
    ("log1p", relax_log1p, jnp.log1p, 0.0, 4.0, 1.5),
    ("1/x", relax_reciprocal, lambda x: 1.0 / x, 0.5, 5.0, 2.0),
]

print(f"{'Function':<10s} {'x':>6s} {'f(x)':>10s} {'cv':>10s} {'cc':>10s} {'gap':>8s}")
print("-" * 56)
for name, relax_fn, true_fn, lb, ub, x_val in demos:
    x = jnp.float64(x_val)
    cv, cc = relax_fn(x, lb, ub)
    f_val = true_fn(x)
    print(
        f"{name:<10s} {float(x):6.2f} {float(f_val):10.4f} "
        f"{float(cv):10.4f} {float(cc):10.4f} {float(cc - cv):8.4f}"
    )
Function        x       f(x)         cv         cc      gap
--------------------------------------------------------
asinh        1.50     1.1948     1.0161     1.1948   0.1787
acosh        2.50     1.5668     1.4028     1.5668   0.1640
atanh        0.40     0.4236     0.4236     0.5282   0.1045
erf          0.50     0.5205     0.2488     0.5205   0.2717
log1p        1.50     0.9163     0.6035     0.9163   0.3128
1/x          2.00     0.5000     0.5000     1.4000   0.9000

Envelope integration in the relaxation compiler#

When the relaxation compiler encounters x**p where x is a variable with known bounds, it automatically uses relax_power_int instead of compositional McCormick. This gives tighter relaxations because:

  1. It uses the actual variable bounds (from the B&B node) rather than propagated interval bounds

  2. The envelope is exact for the monomial, not an approximation from composing simpler relaxations

For example, x^4 via McCormick would compose (x^2)^2, introducing two layers of relaxation error. The envelope computes the exact convex/concave envelope of t -> t^4 directly on [lb, ub].

# Show how the solver automatically uses these features
m = dm.Model("envelope_integration")
x = m.continuous("x", lb=0.5, ub=4)
y = m.continuous("y", lb=0.5, ub=4)
z = m.binary("z")

# x^3 will use relax_power_int, exp(y) will use relax_exp_tight
m.minimize(x**3 + dm.exp(y) + z)
m.subject_to(x + y >= 2)
m.subject_to(x <= 3 * z)

result = m.solve(max_nodes=200)
print(f"Status: {result.status}")
print(f"Objective: {result.objective:.4f}")
print(f"x={result.x['x'].item():.4f}, y={result.x['y'].item():.4f}, z={result.x['z'].item():.0f}")
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for iRow/jCol indices of the jacobian'
cyipopt: b'hessian_cb'
cyipopt: b'Querying for iRow/jCol indices of the Hessian'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'constraints_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'gradient_cb'
cyipopt: b'objective_cb'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
cyipopt: b'hessian_cb'
cyipopt: b'constraints_cb'
cyipopt: b'objective_cb'
cyipopt: b'gradient_cb'
cyipopt: b'jacobian_cb'
cyipopt: b'Querying for jacobian'
cyipopt: b'intermediate_cb'
Status: optimal
Objective: 4.7137
x=0.9675, y=1.0325, z=1

Summary: Solver Options for Tuning#

All these features are enabled automatically by default. Key parameters for tuning:

Parameter

Default

Description

branching_policy

"reliability"

"fractional", "reliability", or "gnn"

cutting_planes

True

Enable RLT + per-constraint OA cuts

mccormick_bounds

"auto"

"auto", "nlp", "lp" (valid global LB; recommended for nonconvex + bilinear, pair with subnlp_frequency=1), "midpoint", "none"

partitions

0

Piecewise McCormick partition count

max_nodes

10000

Node limit for B&B tree

gap_tolerance

1e-4

Relative optimality gap tolerance

When to set mccormick_bounds="lp"#

The default "auto" resolves to "nlp" for nonconvex problems — an NLP relaxation gives a local optimum, which is not a valid global lower bound when the model is nonconvex. For pure-continuous nonconvex problems with bilinear/multilinear terms (pooling, polynomial NLP, QCQP), set mccormick_bounds="lp" and subnlp_frequency=1: this builds the full McCormick LP reformulation (a valid global LB), and the SubNLP heuristic turns each LP primal into a feasible incumbent. On a 22-instance MINLPLib bilinear+continuous sweep this delivered 13 strict wins (5 better objectives, 8 speedups to certified global optimum) over the default — at the cost of 2 instances that lose feasibility when the LP relaxation is loose. Stay with the default for convex or pure-integer models.

The convexity detector, adaptive dispatch, OBBT with incumbent cutoff, and specialized envelopes all operate automatically without user configuration.