[BUG] PaPILO tolerance is hardcoded
@hlinsen is already working on this.
Since Jul 6, 2026.
- Dominant language
- Cuda
- Stars
- 1k
- Forks
- 233
- Avg merge
- 4d 4h
- Merged PRs (30d)
- 95
Description
Describe the bug
The PaPILO tolerance is hard coded in the code in this line:
https://github.com/NVIDIA/cuopt/blame/main/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp#L605-L598C49
Therefore when a tolerance is set by using set_parameter("absolute_primal_tolerance", ...) it get overwritten by that line to 1e-5
presolver.getPresolveOptions().feastol = 1e-5;
Steps/Code to reproduce bug
When using presolve=1 (PaPILO) the postsolve step returns solutions that violate constraints at the 1e-5 level regardless of the user-specified tolerance. The root cause is visible directly in the source: set_presolve_options() in cpp/src/mip_heuristics/presolve/third_party_presolve.cpp (line 598) receives absolute_tolerance as a parameter but never uses it, feastol is assigned a hardcoded constant:
// third_party_presolve.cpp, line 588–598
template <typename i_t, typename f_t>
void set_presolve_options(papilo::Presolve<f_t>& presolver,
problem_category_t category,
f_t absolute_tolerance, // ← received
f_t relative_tolerance, // ← received
f_t time_limit,
bool dual_postsolve,
i_t num_cpu_threads)
{
presolver.getPresolveOptions().tlim = time_limit;
presolver.getPresolveOptions().threads = num_cpu_threads;
presolver.getPresolveOptions().feastol = 1e-5; // ← hardcoded; absolute_tolerance never used
This can be observed at runtime with a self-contained synthetic LP:
import numpy as np
import scipy.sparse as sp
from cuopt.linear_programming import DataModel, Solve, SolverSettings
rng = np.random.default_rng(42)
# Sparse LP with wide coefficient range — triggers the feastol issue
n_vars, n_rows = 8000, 4000
density = 0.002
nnz = int(n_vars * n_rows * density)
rows = rng.integers(0, n_rows, nnz)
cols = rng.integers(0, n_vars, nnz)
# Mix of large and small coefficients (range 1e-6 to 1e4)
vals = np.where(rng.random(nnz) < 0.05,
rng.uniform(1e-6, 1e-4, nnz), # small coefficients
rng.uniform(1.0, 1e4, nnz)) # normal coefficients
A = sp.csr_matrix((vals, (rows, cols)), shape=(n_rows, n_vars))
# Feasible RHS: b = A * x_feasible where x_feasible = 0.5 * ones
x_feasible = np.full(n_vars, 0.5)
b = A @ x_feasible
dm = DataModel()
dm.set_csr_constraint_matrix(A.data.astype(np.float64),
A.indices.astype(np.int32),
A.indptr.astype(np.int32))
dm.set_constraint_lower_bounds(b.astype(np.float64)) # equality: lb = ub = b
dm.set_constraint_upper_bounds(b.astype(np.float64))
dm.set_variable_lower_bounds(np.zeros(n_vars))
dm.set_variable_upper_bounds(np.ones(n_vars))
dm.set_objective_coefficients(rng.random(n_vars))
settings = SolverSettings()
settings.set_parameter("presolve", 1) # PaPILO
settings.set_parameter("absolute_primal_tolerance", 1e-8) # user requests tight tolerance
settings.set_parameter("absolute_dual_tolerance", 1e-8)
settings.set_parameter("time_limit", 60.0)
sol = Solve(dm, settings)
x = np.asarray(sol.get_primal_solution(), dtype=np.float64)
residuals = np.abs(A @ x - b)
print(f"Max constraint violation : {residuals.max():.2e}") # expect ≤ 1e-8, actual ~1e-5
print(f"Rows violated > 1e-6 : {(residuals > 1e-6).sum()} / {n_rows}")
print(f"Rows violated > 1e-8 : {(residuals > 1e-8).sum()} / {n_rows}")
Expected behavior
The postsolve-reconstructed solution should satisfy all constraints to within the user-specified absolute_primal_tolerance. When the user sets absolute_primal_tolerance=1e-8, constraint violations in the returned solution should be ≤ 1e-8. Instead, violations are bounded only by PaPILO's hardcoded feastol=1e-5, which is never updated from the user-facing tolerance API. The parameters absolute_tolerance and relative_tolerance passed into set_presolve_options() are silently ignored.
A suggested fix would be to propagate the user tolerance into feastol:
presolver.getPresolveOptions().feastol = std::max(absolute_tolerance, 1e-7);
with a floor (e.g. 1e-7) to avoid reintroducing the false-infeasibility issue that the previous tightening from 9e-7 → 1e-5 was meant to address.
Environment details (please complete the following information):
Environment location: Bare-metal (Linux 6.5.0, NVIDIA RTX A4500 20 GB, CUDA 12.9)
Method of cuOpt install: pip (pip install cuopt-cu12==26.6.0)
Additional context
As a workaround, setting per_constraint_residual=1 reduces postsolve violation count by ~44% and max violation by ~100× by giving PaPILO a better-converged PDLP iterate to reconstruct from — but does not eliminate the floor set by feastol.
cuOpt performs no verification step after postsolve: even when PaPILO's own internal check reports kFailed, the solver returns status Optimal with no user-facing warning (unless log_to_console=True).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.