Skip to content

[BUG] MIP: settings.set_parameter("presolve", 0) does not disable trivial_presolve #1175

@fetag

Description

@fetag

Summary

settings.set_parameter("presolve", 0) currently disables Papilo presolve, but cuOpt's internal trivial_presolve still runs.

This is surprising from the Python API side: when users set presolve=0, the expectation is that all presolve is disabled. Right now that is not true.

In our case, the remaining trivial_presolve changes the model enough that cuOpt returns a solution that is infeasible in the original formulation. So the immediate issue is:

  • user sets presolve=0
  • Papilo is disabled
  • trivial_presolve still executes
  • returned solution can still be wrong / infeasible for the original model

Expected behavior

When settings.set_parameter("presolve", 0) is used, cuOpt should skip all presolve stages, including trivial_presolve.

At minimum, the behavior should be one of these:

  1. presolve=0 disables both Papilo presolve and trivial_presolve, or
  2. there is a separate public parameter that explicitly disables trivial_presolve

Right now there seems to be no Python-facing way to actually turn presolve fully off.

Actual behavior

With presolve=0, Papilo is skipped, but trivial_presolve still runs.

That makes presolve=0 behave differently from what users would normally expect, and it also removes an important debugging/workaround path: users cannot fully disable presolve to isolate correctness issues.

Why this matters

This is not just a documentation mismatch.

In our repro, the remaining trivial_presolve is exactly the part that still breaks the model/solution correspondence. So even after setting presolve=0, the solver can still return a solution that violates the original constraints.

From the user point of view, this means:

  • presolve=0 is not a reliable escape hatch
  • correctness issues in trivial_presolve cannot be worked around from Python
  • debugging becomes much harder because "presolve off" is only partially true

Reproduction

A minimal repro script is available at repro_cuopt_trivial_presolve_constraint_violation.py.

Run with:

from __future__ import annotations

import numpy as np
import scipy.sparse as sp

from cuopt.linear_programming import DataModel, Solve, SolverSettings


def build_and_solve():
    # Build a small MIP that triggers the trivial_presolve constraint violation.
    #
    # Variables (10 total):
    #   x[0..3]: binary assignment vars (demand constraint 0)
    #   x[4..7]: binary assignment vars (demand constraint 1)
    #   x[8]: continuous auxiliary
    #   x[9]: continuous auxiliary
    #
    # Constraints:
    #   C0: x[0]+x[1]+x[2]+x[3] >= 2   (demand)
    #   C1: x[4]+x[5]+x[6]+x[7] >= 2   (demand)
    #   C2: x[0]+x[4] <= 1             (one-shift cap)
    #   C3: x[1]+x[5] <= 1
    #   C4: x[2]+x[6] <= 1
    #   C5: x[3]+x[7] <= 1
    #   C6: x[8]+x[9] = 1              (aux relationship)
    #   C7: x[8] + x[9] >= 0           <-- THE PROBLEMATIC CONSTRAINT
    #       (trivially satisfied since x[8], x[9] >= 0)
    #
    # The bug: after trivial_presolve fixes some variables and adjusts
    # constraint bounds, the postsolved solution can violate C7.

    n_vars = 10
    rows: list[int] = []
    cols: list[int] = []
    vals: list[float] = []

    # C0: x[0]+x[1]+x[2]+x[3] >= 2
    for i in range(4):
        rows.append(0)
        cols.append(i)
        vals.append(1.0)

    # C1: x[4]+x[5]+x[6]+x[7] >= 2
    for i in range(4, 8):
        rows.append(1)
        cols.append(i)
        vals.append(1.0)

    # C2: x[0]+x[4] <= 1
    rows.append(2); cols.append(0); vals.append(1.0)
    rows.append(2); cols.append(4); vals.append(1.0)

    # C3: x[1]+x[5] <= 1
    rows.append(3); cols.append(1); vals.append(1.0)
    rows.append(3); cols.append(5); vals.append(1.0)

    # C4: x[2]+x[6] <= 1
    rows.append(4); cols.append(2); vals.append(1.0)
    rows.append(4); cols.append(6); vals.append(1.0)

    # C5: x[3]+x[7] <= 1
    rows.append(5); cols.append(3); vals.append(1.0)
    rows.append(5); cols.append(7); vals.append(1.0)

    # C6: x[8]+x[9] = 1
    rows.append(6); cols.append(8); vals.append(1.0)
    rows.append(6); cols.append(9); vals.append(1.0)

    # C7: x[8]+x[9] >= 0  <-- THE PROBLEMATIC CONSTRAINT
    rows.append(7); cols.append(8); vals.append(1.0)
    rows.append(7); cols.append(9); vals.append(1.0)

    n_cnst = 8  # 8 constraints

    # Build CSR matrix
    csr = sp.csr_matrix(
        (
            np.asarray(vals, dtype=np.float64),
            (np.asarray(rows, dtype=np.int32), np.asarray(cols, dtype=np.int32)),
        ),
        shape=(n_cnst, n_vars),
    )

    # RHS: [2, 2, 1, 1, 1, 1, 1, 0]
    rhs = np.array([2.0, 2.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0], dtype=np.float64)
    senses = np.array([">=", ">=", "<=", "<=", "<=", "<=", "==", ">="], dtype=object)
    finite = 1e30
    con_lb = np.where(senses == "<=", -finite, rhs)
    con_ub = np.where(senses == ">=", finite, rhs)

    # Variables: first 8 binary, last 2 continuous
    var_lb = np.zeros(n_vars, dtype=np.float64)
    var_ub = np.full(n_vars, finite, dtype=np.float64)
    var_ub[:8] = 1.0
    var_types = np.full(n_vars, "C", dtype="<U1")
    var_types[:8] = "I"

    # Objective: minimize sum of all vars
    obj = np.ones(n_vars, dtype=np.float64)

    # Build DataModel
    dm = DataModel()
    dm.set_objective_coefficients(obj)
    dm.set_csr_constraint_matrix(
        csr.data.astype(np.float64),
        csr.indices.astype(np.int32),
        csr.indptr.astype(np.int32),
    )
    dm.set_constraint_lower_bounds(con_lb)
    dm.set_constraint_upper_bounds(con_ub)
    dm.set_variable_lower_bounds(var_lb)
    dm.set_variable_upper_bounds(var_ub)
    dm.set_variable_types(var_types)

    # Build settings
    settings = SolverSettings()
    settings.set_parameter("time_limit", 30.0)
    settings.set_parameter("log_to_console", True)

    print("=" * 60)
    print("cuOpt trivial_presolve constraint bound violation repro")
    print("=" * 60)
    print(f"Variables: {n_vars}")
    print(f"Constraints: {n_cnst}")
    print(f"Non-zeros: {len(vals)}")
    print()
    print("Constraint 7: x[8] + x[9] >= 0")
    print("  (trivially satisfied since x[8], x[9] >= 0)")
    print()

    # Solve
    solution = Solve(dm, settings)

    term = (
        solution.get_termination_reason()
        if hasattr(solution, "get_termination_reason")
        else None
    )
    status = (
        solution.get_termination_status()
        if hasattr(solution, "get_termination_status")
        else None
    )
    primal = np.asarray(solution.get_primal_solution(), dtype=np.float64)

    print(f"Termination reason: {term}")
    print(f"Termination status: {status}")
    print(f"Objective value: {solution.get_primal_objective()}")
    print(f"Primal solution length: {len(primal)}")
    print()

    if len(primal) == 0:
        print("No primal solution returned by cuOpt.")
        print("This may indicate an internal error or infeasibility.")
        return 1

    # Verify constraints
    ax = csr.dot(primal)
    tol = 1e-5

    print("Constraint verification:")
    print("-" * 40)

    violations = []
    for c in range(n_cnst):
        lhs = ax[c]
        rhs_c = rhs[c]
        violated = False
        if senses[c] == ">=":
            if lhs < rhs_c - tol:
                violated = True
        elif senses[c] == "<=":
            if lhs > rhs_c + tol:
                violated = True
        elif senses[c] == "==":
            if abs(lhs - rhs_c) > tol:
                violated = True

        status_str = "VIOLATED" if violated else "OK"
        marker = " <<<" if violated else ""
        print(f"  C{c}: LHS={lhs:.6f}, RHS={rhs_c:.6f} [{status_str}]{marker}")

        if violated:
            violations.append((c, lhs, rhs_c))

    print()
    if violations:
        print(f"BUG CONFIRMED: {len(violations)} constraint(s) violated!")
        for c, lhs, rhs_c in violations:
            if c == 7:
                print()
                print(f"  *** Constraint {c} (x[8] + x[9] >= 0) VIOLATED ***")
                print(f"      LHS = {lhs:.6f}, RHS = {rhs_c:.6f}")
                print(f"      Violation = {rhs_c - lhs:.6f}")
                print()
                print(f"      This is the trivial_presolve bug: a constraint that")
                print(f"      should be trivially satisfied (both vars >= 0) is")
                print(f"      violated after presolve/postsolve.")
        return 1
    else:
        print("All constraints satisfied. No violations found.")
        return 0


if __name__ == "__main__":
    import sys
    sys.exit(build_and_solve())

The key point is:

settings.set_parameter("presolve", 0)

but cuOpt still appears to run internal trivial presolve logic.

Requested fix

Please make settings.set_parameter("presolve", 0) disable trivial_presolve as well.

If that is not intended, then please expose a separate public option to disable trivial_presolve explicitly, because right now users have no way to fully turn presolve off.

Nice-to-have

If possible, it would also help to clarify this in docs/logging:

  • whether presolve means only Papilo presolve, or all presolve
  • whether trivial_presolve is always on
  • which presolve stages actually ran for a solve

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions