Fix #3170: discard a redundant variable bound instead of substituting it as if tight#3172
Fix #3170: discard a redundant variable bound instead of substituting it as if tight#3172EamonHetherton wants to merge 2 commits into
Conversation
|
Thanks for this. We don't accept AI-generated code, but I expect that @Opt-Mucca and @fwesselm will identify what needs to be changed and fix it themselves. |
|
@jajhall ok, I have two other issues I'm working through for a minimal reproduction, one is already logged as an issue with more detail to come. Essentially I am verifying a larger model across hundreds of thousands of solves with different inputs and occasionally HiGHS was returning a noticeably sub-optimal solution and found three discrete issues causing them. Would you rather I just report the issue as best I can or open a pull request with a demonstrated fix (albeit with AI assistance) for you to cherry pick from? |
…iable bound HighsTransformedLp::transform() ignores the `redundant` flag returned by HighsImplications::cleanupVub()/cleanupVlb(). A redundant variable bound is returned untightened for the caller to discard, but transform() substitutes it anyway, so cut generation is given a false upper bound on the transformed variable and can produce a cut that is not globally valid. When such a cut removes the optimum the search closes and reports the incumbent as Optimal at a zero gap, with no warning and no violated row. 3170-1.mps is solved incorrectly with `presolve=off` alone, reporting -21262719.4302 against an optimum of -21446579.364727 - a relative error of 0.86%, about 86x the default relative gap tolerance. 3170-2.mps needs the tighter option set in the test and reports -516096644.644 against an optimum of -516307714.782682. Both optima were verified independently of the MIP search by enumerating every binary assignment and solving the resulting LP. This commit adds the test and the two instances only, so the failure is visible on its own. The fix follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stituting it transform() substitutes a variable upper bound x <= a*z + b by the slack y = a*z + b - x and passes upper[j] = ub - lb to the cut generator as y's upper bound. That is sound only if the variable bound is at least as tight as the simple bound, i.e. maxValue() <= ub, so the code calls cleanupVub() to re-establish that when it does not hold. cleanupVub() only tightens a variable bound that is not redundant. When minValue() >= ub - feastol it reports redundant = true and deliberately leaves the bound unchanged for the caller to discard, as cleanupVarbounds() does. The caller here ignored that flag and substituted the bound anyway, so the slack could range far above the ub - lb it was declared to have, and the cut derived from it was not globally valid. cleanupVlb() has the same contract and the same caller bug. Discard the variable bound when cleanup reports it redundant, and defensively whenever the assumption still fails afterwards, falling back to the simple bound. Nothing is lost: a redundant variable bound is no stronger than the simple bound. This makes the issue-3170 test pass. The full unit test suite passes, and all 105 models in check/instances return an identical status and objective. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
If you can generate a fix, then it it certainly helps. Our developers may well implement it as it is, but any adverse effects need to be considered |
9f45c6a to
63f0048
Compare
|
Thanks, I'll continue as is then; log the issue with as much detail as I can gather and open a PR with a failing test followed by a proposed fix. Cherry pick as you like for what goes upstream. (and i'll rebase to latest too) |
Fixes #3170.
The first commit adds the two reproducers as a failing regression test, on its own, so the defect
is visible without any fix applied. The second commit contains the fix.
The models
check/instances/3170-1.mps— 39x30, 10 binaries. Solved incorrectly withpresolve=offalone:reports
-21262719.4302against an optimum of-21446579.364727, a relative error of 0.86%,about 86x the default relative gap tolerance, so not a tolerance artefact.
check/instances/3170-2.mps— 40x24, 8 binaries. Needs the tighter option set used in the test;reports
-516096644.644against an optimum of-516307714.782682.Both optima were verified independently of the MIP search, by enumerating every binary assignment
and solving the resulting LP for each.
The defect
HighsTransformedLp::transform()substitutes a variable upper boundx <= a*z + bby the slacky = a*z + b - x, and passesupper[j] = ub - lbto the cut generator asy's upper bound. Thatis sound only if the variable bound is at least as tight as the simple bound, i.e.
maxValue() <= ub, so the code callscleanupVub()to re-establish that when it does not hold.cleanupVub()only tightens a variable bound that is not redundant. WhenminValue() >= ub - feastolit reportsredundant = trueand deliberately leaves the boundunchanged, for the caller to discard — which is what
cleanupVarbounds()does. The caller hereignored that flag and substituted the bound anyway, so the slack could range far above the
ub - lbit was declared to have, and the cut derived from it was not globally valid. Such a cutcan remove the optimum, after which the search closes and reports the incumbent as
Optimalat azero gap.
cleanupVlb()has the identical contract and the identical caller bug.The fix
Discard the variable bound when cleanup reports it redundant, and defensively whenever the
assumption still fails afterwards, falling back to the simple bound. Nothing is lost, since a
redundant variable bound is by definition no stronger than the simple bound.
and symmetrically for the variable lower bound.
Two things to be aware of when reviewing
Only the variable upper bound path is exercised by the tests. The variable lower bound half is
fixed by symmetry, since
cleanupVlb()has the same contract and the same caller bug, but neitherattached model reaches it.
The second disjunct is belt-and-braces. After a successful
cleanupVub()the invariant holds,so
|| maxValue() > ub + feastolshould not fire; theredundantflag alone is what these modelshit. Happy to drop it if you would rather the change were minimal.
Adverse effects
The concern with this change is that it discards a variable bound that would otherwise have been
used for cut generation, which could weaken cuts. Measured on
latest:check/instancesSo there is no collateral change: no weakened cuts, no extra nodes, no extra iterations anywhere
the defect is not present. The fix appears to be inert unless the defect actually fires. That is
not a proof that it never activates — those 400 models come from the same generator that produces
triggers for this defect at roughly 1 in 500, so most likely none of them hit it.
One thing that might look like a regression but is not: on the larger model this was first found
on, the node count rises with the fix (7 to 18). That is the search doing its job — without the fix
an invalid cut was closing the tree early.
Testing
issue-3170test fails onlatestat the first commit and passes at the second;