Skip to content

MIP: path-mixing cut is untransformed with different bound substitutions than its coefficients were built with, producing an invalid cut #3171

Description

@EamonHetherton

Summary

In HighsPathSeparator::separateLpSolution(), a path-mixing cut is assembled from several base
rows, each transformed by its own HighsTransformedLp::transform() call, and mapped back by a
single untransform() at the end. The chosen substitution per column lives in the shared
boundTypes array, and untransform() reads whatever is in there when it runs.

A base row that is transformed and then discarded leaves its changes behind. If it flipped the
complementation chosen for a binary, untransform() resubstitutes a different variable than the
coefficients were computed against, and the resulting cut is not globally valid. Such a cut can
remove the optimum, after which the search closes and reports the incumbent as Optimal at a zero
gap.

Reproducing

3171-1.mps.txt (85 rows, 99 columns, 49 binaries, 300 nonzeros) is solved incorrectly under default
options
, with no option pinning:

$ highs 3171-1.mps

  Status            Optimal
  Primal bound      42332.2356068
  Dual bound        42332.2356068
  Gap               0% (tolerance: 0.01%)
  Nodes             0

The optimum is 42215.5250005, so this is suboptimal by 116.71 — a relative error of 0.276%,
about 28x the default relative gap tolerance
. Not a tolerance artefact, and decided at the root
in zero nodes.

The optimum is confirmed by HiGHS itself, without trusting a patched build: fixing all 49 integer
columns to the values of the better point and solving the remaining LP returns
Optimal 42215.5250005. So a feasible integral point 116.71 better than the reported optimum
demonstrably exists. --presolve off returns 42215.5250005 too, so the contradiction is visible
within one unmodified binary.

The failure is seed-dependent — it occurs on seeds 0 and 5 of the first 8 — so a regression test
needs random_seed pinned to 0 (which is the default).

Cause

The bound type for a binary depends on the sign of its coefficient in the row being transformed
(highs/mip/HighsTransformedLp.cpp):

} else if (vals[i] > 0) {
  boundTypes[col] = BoundType::kSimpleLb;   // y = x - lb
} else {
  boundTypes[col] = BoundType::kSimpleUb;   // y = ub - x   (complemented)
}

so a column occurring with opposite signs in two rows gets two different substitutions.

In highs/mip/HighsPathSeparator.cpp the loop transforms base row k before testing whether
its right-hand side keeps the required monotonicity, and drops the row only afterwards:

if (!transLp.transform(aggregatedPath[k].second, tmpUpper, tmpSolval,
                       aggregatedPath[k].first, rhs[k], integralPositive)) {
  pathLen = k;                 // row dropped - but boundTypes already mutated
  break;
}
...
} else if (rhs[k] >= rhs[k - 1] - mip.mipdata_->feastol) {
  pathLen = k;                 // row dropped - but boundTypes already mutated
  break;
}

The mutation survives the row being discarded, and nothing ties the substitutions in force at
untransform() to the ones used to build the coefficients.

The existing consistency assertions in that loop cannot catch it: solval is lbDist[col] for
both kSimpleLb and kVariableLb and ubDist[col] for both kSimpleUb and kVariableUb, while
upper is ub - lb in every case — so neither value changes when the substitution flips.

Also on a production model

On a 10404 x 18899 MIP this fires once and costs 2978.48 on the objective. Instrumenting the
separator to compare the substitution used when each coefficient was computed against the one in
force at untransform() gives exactly one mismatched column:

col 14096   substitution at untransform() = kSimpleLb
            y used when the coefficient was computed = 1     (complemented, y = 1 - x)
            y implied by the substitution used        = 0     (y = x - 0)
            coefficient -1   ->   error exactly +1

That cut evaluates to violation 0 in the transformed space at the known optimum and 1 after
untransform() — the entire error is the one flipped complementation. The invalid cut (53
nonzeros, all ±1, rhs -1) then seeds further invalid cuts and a bogus clique via
extractCliquesFromCut().

The model still returns the wrong answer with the fixes for #3170 and #3173 both applied, so the
three defects are independent:

build objective, presolve=on
latest -12370583.0955 wrong
latest + #3170 + #3173 -12370583.0955 wrong
latest + #3170 + #3173 + this -12373561.5756 correct

presolve=off returns the correct -12373561.5756 throughout. I can attach the model, or the
instrumentation patch that makes the mismatch observable in a single solve.

The same pattern affects the variable bounds too

bestVub / bestVlb are shared mutable state as well, and transform() may tighten them in
place via cleanupVub() / cleanupVlb(), so a coefficient computed from an earlier base row can
be untransformed against a tightened bound. I have no reproducer for that case, but it is the same
failure mode: the cut is untransformed by reading state back out of a shared, still-mutating
HighsTransformedLp, rather than carrying the transformation it was built with.

Related

#3170 — an independent unsoundness in the same subsystem, where a redundant variable bound is
substituted as if it were tight. Different root cause, same end effect, same call path; neither fix
subsumes the other.

#3173 — a third defect found on the same class of model, but in presolve rather than cut
generation, so it is decided before branch-and-bound runs and is unaffected by either fix here.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions