From e4392d6bdae8f7f99023c715afc7613245535b03 Mon Sep 17 00:00:00 2001 From: Eamon Hetherton Date: Mon, 27 Jul 2026 11:32:54 +1000 Subject: [PATCH 1/2] Add failing test for #3173: presolve fixes a near-fixed column 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) --- check/TestMipSolver.cpp | 36 +++++ check/instances/3173-1.mps | 288 +++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 check/instances/3173-1.mps diff --git a/check/TestMipSolver.cpp b/check/TestMipSolver.cpp index d6a4d35d0d4..ae6da640fe1 100644 --- a/check/TestMipSolver.cpp +++ b/check/TestMipSolver.cpp @@ -1605,3 +1605,39 @@ TEST_CASE("issue-3118a", "[highs_test_mip_solver]") { highs.resetGlobalScheduler(true); } + +TEST_CASE("issue-3173", "[highs_test_mip_solver]") { + // 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, but not of the objective, so + // a column that is nearly fixed in the constraints but has a large cost can + // be pinned at the wrong end of its range, losing |cost| * (ub - lb). + // + // On this model the column has bounds differing by 3.4e-8 and a cost of + // -7.3e10, so pinning it at the lower bound costs 2496.5. Presolve reduces + // the model to empty and reports that value as optimal, with default options + // and no branch-and-bound nodes. + // + // The optimal objective was verified independently of the MIP solver by + // enumerating all 2^8 binary assignments and solving the resulting LP for + // each. + const std::string filename = + std::string(HIGHS_DIR) + "/check/instances/3173-1.mps"; + const double optimal_objective = -39829.5816585168; + Highs highs; + highs.setOptionValue("output_flag", dev_run); + REQUIRE(highs.readModel(filename) == HighsStatus::kOk); + REQUIRE(highs.run() == HighsStatus::kOk); + REQUIRE(highs.getModelStatus() == HighsModelStatus::kOptimal); + // The column is still pinned within the feasibility tolerance, so allow a + // small relative error - but nothing like the 6.3% the defect produces. + const double relative_error = + std::fabs(highs.getInfo().objective_function_value - optimal_objective) / + std::max(1.0, std::fabs(optimal_objective)); + if (dev_run) + printf("issue-3173: objective %g, require %g, relative error %g\n", + highs.getInfo().objective_function_value, optimal_objective, + relative_error); + REQUIRE(relative_error < 1e-6); + highs.resetGlobalScheduler(true); +} diff --git a/check/instances/3173-1.mps b/check/instances/3173-1.mps new file mode 100644 index 00000000000..01374c10d64 --- /dev/null +++ b/check/instances/3173-1.mps @@ -0,0 +1,288 @@ +NAME HIGHSBUG +ROWS + N COST + L r0 + L r1 + L r2 + L r3 + L r4 + L r5 + L r6 + L r7 + L r8 + L r9 + L r10 + L r11 + L r12 + L r13 + L r14 + L r15 + L r16 + L r17 + L r18 + L r19 + L r20 + L r21 + L r22 + L r23 + L r24 + L r25 + L r26 + L r27 + L r28 + L r29 + L r30 + L r31 + L r32 + L r33 + L r34 + L r35 + L r36 + L r37 + L r38 + L r39 +COLUMNS + MARKER 'MARKER' 'INTORG' + c0 COST -1000000 + c0 r0 -128 + c0 r7 128 + c0 r13 64 + c0 r15 10 + c0 r17 2.5 + c0 r19 -2.5 + c0 r22 10 + c0 r25 -12 + c0 r33 -3 + c0 r39 17 + c1 COST -1 + c1 r0 -20 + c1 r2 2 + c1 r3 -20 + c1 r5 -2 + c1 r11 128 + c1 r18 -2.5 + c1 r21 -1 + c1 r23 -16 + c1 r30 -40 + c1 r34 -1.5 + c1 r35 -1 + c1 r36 -2.5 + c2 COST -10000 + c2 r2 -32 + c2 r10 3 + c2 r22 -8 + c2 r29 1 + c2 r37 -10 + c3 COST -0.25 + c3 r4 -1.5 + c3 r9 -100 + c3 r12 -3 + c3 r13 50 + c3 r20 -0.5 + c3 r22 -10 + c3 r29 1 + c3 r32 -300 + c3 r34 -10 + c4 COST -0.5 + c4 r1 4 + c4 r8 -3 + c4 r9 -20 + c4 r11 -10 + c4 r16 50 + c4 r20 1 + c4 r21 -16 + c4 r34 50 + c4 r38 -100 + c5 COST -5 + c5 r3 100 + c5 r5 16 + c5 r6 128 + c5 r7 -100 + c5 r13 -10 + c5 r15 5 + c5 r18 5 + c5 r20 -64 + c5 r23 5 + c5 r28 1 + c5 r36 -62.5 + c6 COST -5 + c6 r0 -10 + c6 r1 -8 + c6 r3 -32 + c6 r21 64 + c6 r23 -20 + c6 r26 -4 + c6 r28 1 + c6 r31 -10 + c6 r35 -2.5 + c6 r37 20 + c6 r39 8 + c7 COST -5 + c7 r1 1.5 + c7 r4 19 + c7 r14 64 + c7 r24 -200 + c7 r27 -2 + c7 r29 1 + MARKER 'MARKER' 'INTEND' + c8 COST -10000 + c8 r0 64 + c8 r31 1 + c9 COST -2.5 + c9 r0 -20 + c9 r1 3 + c10 COST -2.5 + c10 r1 -100 + c10 r2 2 + c10 r33 0.5 + c10 r35 20 + c11 COST -0.5 + c11 r2 -16 + c11 r3 100 + c12 COST -1000000 + c12 r3 -32 + c12 r4 64 + c12 r34 -2 + c13 COST -0.5 + c13 r4 -5 + c13 r5 100 + c14 COST -100 + c14 r5 -16 + c14 r6 64 + c14 r24 1 + c14 r25 1 + c14 r26 1 + c14 r27 1 + c15 COST -10 + c15 r6 -10 + c15 r7 5 + c16 COST -100 + c16 r7 -0.5 + c16 r8 20 + c17 COST -1 + c17 r8 -4 + c17 r9 32 + c17 r32 1 + c17 r37 -8 + c18 COST -10000 + c18 r9 -3 + c18 r10 50 + c18 r37 128 + c19 COST -100 + c19 r10 -8 + c19 r11 16 + c20 COST -0.25 + c20 r11 -5 + c20 r12 50 + c20 r35 1 + c21 COST -100 + c21 r12 -128 + c21 r13 16 + c22 COST -2.5 + c22 r13 -64 + c22 r14 64 + c23 COST -10 + c23 r14 -100 + c23 r15 50 + c24 COST -1 + c24 r15 -3 + c24 r16 4 + c24 r34 20 + c24 r35 -128 + c25 COST -100 + c25 r16 -50 + c25 r17 1 + c25 r30 1 + c26 COST -10 + c26 r17 -16 + c26 r18 128 + c26 r36 32 + c27 COST -1 + c27 r18 -0.5 + c27 r19 8 + c28 COST -100 + c28 r19 -64 + c28 r20 20 + c29 COST -10000 + c29 r20 -10 + c29 r21 10 + c29 r38 100 + c30 COST -0.5 + c30 r21 -1 + c30 r22 100 + c31 COST -2.5 + c31 r22 -2.5 + c31 r23 1.5 + c32 COST -9.9999999999999995e-07 + c32 r23 -8 +RHS + RHS r0 12 + RHS r1 20 + RHS r2 5 + RHS r5 1 + RHS r7 12 + RHS r8 33 + RHS r9 7 + RHS r10 9 + RHS r11 20 + RHS r12 5 + RHS r13 5 + RHS r14 2 + RHS r15 7 + RHS r16 7 + RHS r17 3 + RHS r20 7 + RHS r22 2 + RHS r23 20 + RHS r24 40 + RHS r25 14 + RHS r26 9.0999999999999996 + RHS r27 5.915 + RHS r28 1 + RHS r29 1 + RHS r30 10 + RHS r31 2 + RHS r32 10 + RHS r33 2 + RHS r34 2 + RHS r35 2 + RHS r36 9 + RHS r37 20 + RHS r38 2 + RHS r39 5 +RANGES +BOUNDS + UP BND c0 1 + UP BND c1 1 + UP BND c2 1 + UP BND c3 1 + UP BND c4 1 + UP BND c5 1 + UP BND c6 1 + UP BND c7 1 + UP BND c8 20 + UP BND c9 1000 + UP BND c10 1000 + UP BND c11 20 + UP BND c12 50 + UP BND c13 20 + UP BND c14 200 + UP BND c15 100 + UP BND c16 100 + UP BND c17 200 + UP BND c18 100 + UP BND c19 500 + UP BND c20 100 + UP BND c21 1000 + UP BND c22 1000 + UP BND c23 50 + UP BND c24 100 + UP BND c25 50 + UP BND c26 500 + UP BND c27 500 + UP BND c28 50 + UP BND c29 500 + UP BND c30 200 + UP BND c31 200 + UP BND c32 20 +ENDATA From 729d68ec62cf36f3de74729a4378129e8fd6f760 Mon Sep 17 00:00:00 2001 From: Eamon Hetherton Date: Mon, 27 Jul 2026 11:33:03 +1000 Subject: [PATCH 2/2] Fix #3173: only treat a column as fixed if the objective perturbation 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) --- highs/presolve/HPresolve.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/highs/presolve/HPresolve.cpp b/highs/presolve/HPresolve.cpp index 954e432d2f0..5aed724e285 100644 --- a/highs/presolve/HPresolve.cpp +++ b/highs/presolve/HPresolve.cpp @@ -2226,9 +2226,20 @@ HPresolve::Result HPresolve::checkColBounds(HighsInt col, bool* isFixed) { assert(!colDeleted[col]); double boundDiff = model->col_upper_[col] - model->col_lower_[col]; if (isFixed != nullptr) *isFixed = false; + // The bounds are only equal to within the feasibility tolerance, so fixing + // the column perturbs the row activities by at most + // getMaxAbsColVal(col) * boundDiff and the objective by at most + // |cost| * boundDiff. Both have to be negligible: a column can be nearly + // fixed in the constraints while still being worth a great deal in the + // objective, and fixing it at the wrong end of its range then loses that + // amount. Note that for an empty column the row activity term is zero + // whatever boundDiff is, so without the objective term such a column would + // always be treated as fixed here, pre-empting emptyCol() which fixes it at + // the bound that is best for the objective. if (boundDiff <= primal_feastol && (boundDiff <= options->small_matrix_value || - getMaxAbsColVal(col) * boundDiff <= primal_feastol)) { + (getMaxAbsColVal(col) * boundDiff <= primal_feastol && + std::fabs(model->col_cost_[col]) * boundDiff <= primal_feastol))) { // check for primal infeasibility if (boundDiff < -primal_feastol) return Result::kPrimalInfeasible; // check for unboundedness