Skip to content

Fix #3173: bound the objective perturbation when treating a near-fixed column as fixed#3174

Open
EamonHetherton wants to merge 2 commits into
ERGO-Code:latestfrom
EamonHetherton:issue-3173
Open

Fix #3173: bound the objective perturbation when treating a near-fixed column as fixed#3174
EamonHetherton wants to merge 2 commits into
ERGO-Code:latestfrom
EamonHetherton:issue-3173

Conversation

@EamonHetherton

@EamonHetherton EamonHetherton commented Jul 27, 2026

Copy link
Copy Markdown

Addresses #3173.

The first commit adds the reproducer as a failing regression test, on its own, so the defect is
visible without any fix applied. The second commit contains a candidate change. They are separate
deliberately: the test is the part I am confident about, and the threshold in the fix is a
judgement call I would rather you made — see below. Taking only the first commit is a perfectly
reasonable outcome.

Provenance

This was not encountered in a real model. It turned up in a randomly generated one, from a
generator written to produce minimal reproducers for #3170 and #3171, and the condition is reached
zero times on the two real models that motivated those. So this is a defect found by construction
rather than one observed in practice, and that is worth weighing when deciding how much churn it
justifies.

The counterweight: the column being fixed has a cost of -7.29e10, but the model's own cost range
is [1e-06, 1e+06]. The coefficient is about 73,000x larger than anything in the input, built up
by presolve's own substitutions before the column reaches checkColBounds(). Reaching this state
does not require an extreme cost in the MPS file, only an awkward one plus a few substitutions.

What the defect is

HPresolve::checkColBounds() treats a column as fixed when its bounds are equal only within the
feasibility tolerance. The condition bounds the resulting perturbation of the row activities:

getMaxAbsColVal(col) * boundDiff <= primal_feastol

but not the perturbation of the objective, and colPresolve() then pins the column at its lower
bound. On the model added here:

lb           1.3502174835205079
ub           1.3502175177853328
boundDiff    3.426482e-08     <= mip_feasibility_tolerance, so "fixed"
maxAbsColVal 0                <- empty column, the row-activity guard is vacuous
cost         -72859132125.478302

|cost| * boundDiff = 2496.5050 against an observed objective error of 2496.5054. Presolve
reduces the model to empty, reports Presolve: Optimal, and returns a value 6.3% away from the
optimum with default options and no branch-and-bound nodes.

The optimum was verified independently of the MIP search, by enumerating all 2^8 binary assignments
and solving the resulting LP for each.

The candidate change

Require the objective perturbation to be negligible as well:

  if (boundDiff <= primal_feastol &&
      (boundDiff <= options->small_matrix_value ||
       (getMaxAbsColVal(col) * boundDiff <= primal_feastol &&
        std::fabs(model->col_cost_[col]) * boundDiff <= primal_feastol))) {

The column then falls through to emptyCol(), which fixes it at the cost-optimal bound. The error
drops from 2496.5 to 2.4e-5 (6e-10 relative) and the regression test passes.

Why the threshold is an open question

primal_feastol is a feasibility tolerance being used to bound an objective quantity. It is
convenient and symmetric with the row term, not principled. Alternatives, none obviously right:

  • mip_abs_gap is dimensionally correct for a MIP, but meaningless for an LP, and users
    legitimately set it to 0, which would disable the reduction unconditionally;
  • something relative to the objective magnitude is most defensible in principle, but there is no
    reliable objective scale available at that point in presolve;
  • dispatching to emptyCol() whenever colsize[col] == 0, before the tolerance check, fixes the
    empty-column case specifically but not a non-empty column with a large cost and a small
    boundDiff, where the row-activity term is satisfied and |cost| * boundDiff is still large.

Detection is insensitive to the choice: the failing case exceeds any candidate threshold by about
nine orders of magnitude. The risk is in the other direction, suppressing reductions that were
doing useful work.

How often the condition is reached, and what suppressing it costs

I instrumented the condition and counted every column reaching it with boundDiff > 0 and the
row-activity term already satisfied.

Small models — 35 MIP models from check/instances, 400 generated MIPs, and this reproducer,
435 in total. Only 11 columns reach it at all. Their |cost| * boundDiff:

min     2.502295e-14
median  5.889381e-06
max     2.496505e+03      <- this bug

primal_feastol blocks 10 of those 11. Across all 435 models, status, objective, node count and LP
iteration count are identical with and without the extra condition.

Larger models — two MIPs of 13724 x 24810 and 10404 x 18899. The condition is reached zero
times on both, and presolve reduction counts, node counts and LP iteration counts are identical
with and without the change:

                   presolve reductions                              nodes  LP iters
model 1 before     rows 10134(-3590); cols 19268(-5542); nz 64905   1      7087
model 1 after      rows 10134(-3590); cols 19268(-5542); nz 64905   1      7087
model 2 before     rows  7350(-3054); cols 14210(-4689); nz 48859   135    27980
model 2 after      rows  7350(-3054); cols 14210(-4689); nz 48859   135    27980

What that does and does not establish. On the larger models the change is inert — the
condition never fires, so those runs say nothing about the cost of suppressing the reduction, only
that the change does not perturb models where the situation does not arise. The evidence that
suppression is free comes only from the small models, where it is free but the models are small.

So I cannot rule out a performance cost on a large model where this reduction does fire and is
load-bearing. I have not benchmarked against MIPLIB or anything at production scale. What the
counts do suggest is that the situation is rare: 11 occurrences in 435 small models and none in two
models of ~10^4 rows. That bounds how often any cost could be incurred, not how large it might be
when it is.

If that matters for the decision, a run over your own benchmark set would settle it, and I am happy
to adjust the threshold to whatever you would prefer.

A caveat on the change itself

Bounding |cost| * boundDiff bounds only the direct objective error. Fixing a column also
perturbs row activities, and downstream that can flip an integer decision worth far more than the
bound suggests. The condition is necessary; I would not claim it is sufficient.

An approach I would advise against

Pinning at the cost-optimal bound instead of always the lower one. It fixes this model, but only
converts a pessimistic error into an optimistic one of the same size, i.e. a dual bound better than
anything actually achievable, which is worse.

Testing

  • the new issue-3173 test fails on latest at the first commit and passes at the second;
  • the full unit test suite passes (1259304 assertions in 335 test cases);
  • the 435 small models and 2 larger models above are unchanged in status, objective, node count and
    LP iteration count.

🤖 Generated with Claude Code

EamonHetherton and others added 2 commits July 27, 2026 11:32
…mn ignoring its cost

HPresolve::checkColBounds() treats a column as fixed when its bounds are equal
only within the feasibility tolerance. The condition bounds the resulting
perturbation of the row activities:

    getMaxAbsColVal(col) * boundDiff <= primal_feastol

but not the perturbation of the objective. A column can be nearly fixed in the
constraints while still being worth a great deal in the objective, and
colPresolve() then pins it at its lower bound, losing up to
|cost| * (ub - lb).

On the model added here that column has

    lb           1.3502174835205079
    ub           1.3502175177853328
    boundDiff    3.426482e-08     <= mip_feasibility_tolerance, so "fixed"
    maxAbsColVal 0                <- empty column, the guard above is vacuous
    cost         -72859132125.478302

so |cost| * boundDiff = 2496.5050 against an observed objective error of
2496.5054. Presolve reduces the model to empty, reports Presolve: Optimal and
returns -37333.0762329 where the optimum is -39829.5816585168 - 6.3% out, with
default options and no branch-and-bound nodes.

The optimum was verified independently of the MIP search by enumerating all
2^8 binary assignments and solving the resulting LP for each.

This commit adds the test and the instance only, so the failure is visible on
its own. The fix follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…turbation is negligible

checkColBounds() declares a column fixed when its bounds are equal to within
the feasibility tolerance. The condition bounded the perturbation of the row
activities that fixing it causes, but not the perturbation of the objective,
and colPresolve() then pins the column at its lower bound.

Require the objective term to be negligible as well. For an empty column the
row activity term is zero whatever boundDiff is, so without this the column was
always treated as fixed here, pre-empting emptyCol(), which is what fixes an
empty column at the bound that is best for the objective. With the change such
a column falls through to emptyCol() and is fixed at the correct bound.

On the model added in the previous commit the objective error drops from
2496.5054 to 2.4e-5 (6e-10 relative), and the issue-3173 test passes.

The full unit test suite passes, and all 35 MIP models in check/instances
return an identical status, objective, node count and LP iteration count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@odow

odow commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

It's worth noting that HiGHS differs from Gurobi here.

julia> optimize!(model)
Running HiGHS 1.15.1 (git hash: 04024d701f): Copyright (c) 2026 under MIT licence terms
Includes third-party software components, see THIRD_PARTY_NOTICES.md for full details
Using BLAS: Apple Accelerate 
MIP has 40 rows; 33 cols; 138 nonzeros; 8 integer variables (8 binary)
Coefficient ranges:
  Matrix  [5e-01, 3e+02]
  Cost    [1e-06, 1e+06]
  Bound   [1e+00, 1e+03]
  RHS     [1e+00, 4e+01]
WARNING: Problem has some excessively small costs
Presolving model
38 rows, 30 cols, 114 nonzeros 0s
28 rows, 25 cols, 98 nonzeros 0s
23 rows, 20 cols, 82 nonzeros 0s
17 rows, 14 cols, 50 nonzeros 0s
13 rows, 11 cols, 41 nonzeros 0s
11 rows, 8 cols, 24 nonzeros 0s
0 rows, 0 cols, 0 nonzeros 0s
Presolve reductions: rows 0(-40); columns 0(-33); nonzeros 0(-138) - Reduced to empty
Presolve: Optimal

Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
     I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
     S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
     Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero

        Nodes      |    B&B Tree     |            Objective Bounds              |  Dynamic Constraints |       Work      
Src  Proc. InQueue |  Leaves   Expl. | BestBound       BestSol              Gap |   Cuts   InLp Confl. | LpIters     Time

         0       0         0   0.00%   -37333.076244   -37333.076244      0.00%        0      0      0         0     0.0s

Solving report
  Status            Optimal
  Primal bound      -37333.0762443
  Dual bound        -37333.0762443
  Gap               0% (tolerance: 0.01%)
  P-D integral      0
  Solution status   feasible
                    -37333.0762504 (objective)
                    0 (bound viol.)
                    0 (int. viol.)
                    0 (row viol.)
  Timing            0.00
                    0.00 (Presolve)
                    0.00 (Solve)
                    0.00 (Postsolve)
  Max sub-MIP depth 0
  Nodes             0
  Repair LPs        0
  LP iterations     0

julia> exit()
(base) odow@Mac SDDP % gurobi_cl ~/Downloads/3173-1.mps
Set parameter WLSAccessID
Set parameter WLSSecret
Set parameter LicenseID to value 722777
Set parameter LogFile to value "gurobi.log"
Using license file /Users/odow/gurobi.lic
WLS license 722777 - registered to JuMP Development

Gurobi Optimizer version 12.0.3 build v12.0.3rc0 (mac64[arm] - Darwin 25.5.0 25F80)
Copyright (c) 2025, Gurobi Optimization, LLC

Read MPS format model from file /Users/odow/Downloads/3173-1.mps
Reading time = 0.00 seconds
HIGHSBUG: 40 rows, 33 columns, 138 nonzeros

Using Gurobi shared library /Library/gurobi1203/macos_universal2/lib/libgurobi120.dylib

CPU model: Apple M4
Thread count: 10 physical cores, 10 logical processors, using up to 10 threads

WLS license 722777 - registered to JuMP Development
Optimize a model with 40 rows, 33 columns and 138 nonzeros
Model fingerprint: 0x5b1968c2
Variable types: 25 continuous, 8 integer (0 binary)
Coefficient statistics:
  Matrix range     [5e-01, 3e+02]
  Objective range  [1e-06, 1e+06]
  Bounds range     [1e+00, 1e+03]
  RHS range        [1e+00, 4e+01]
Found heuristic solution: objective -13807.35189
Found heuristic solution: objective -14074.01856
Presolve removed 40 rows and 33 columns
Presolve time: 0.00s
Presolve: All rows and columns removed

Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)
Thread count was 1 (of 10 available processors)

Solution count 4: -39829.6 -39829.6 -14074 -13807.4 
No other solutions better than -39829.6

Optimal solution found (tolerance 1.00e-04)
Best objective -3.982958165852e+04, best bound -3.982958165852e+04, gap 0.0000%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants