Skip to content

Fix knapsack rational coefficient scaling - #1754

Draft
aliceb-nv wants to merge 5 commits into
mainfrom
knapsack-cover-floor-fix
Draft

Fix knapsack rational coefficient scaling#1754
aliceb-nv wants to merge 5 commits into
mainfrom
knapsack-cover-floor-fix

Conversation

@aliceb-nv

Copy link
Copy Markdown
Contributor

Knapsack cover cuts could be invalid: rational_coefficients scales rows in floating point so coefficients land an ulp low, and the separation DP floored weights, so it returned oversized sets whose complement is not a cover.

Description

Issue

Checklist

  • I am familiar with the Contributing Guidelines.
  • Testing
    • New or existing tests cover these changes
    • Added tests
    • Created an issue to follow-up
    • NA
  • Documentation
    • The documentation is up to date with these changes
    • Added new documentation
    • NA

The scaling by lcm(den)/gcd(num) is done in floating point, so a coefficient
whose exact value is integral can come out an ulp below it, e.g. 135.45 scaled
by 100 yields 13544.999999999998. Both callers treat the returned row as
integral, which is what makes the knapsack cover test sum_C a_j > beta
equivalent to sum_C a_j >= beta + 1.

Snap the integer-variable coefficients onto their integers after scaling and
report failure when one is not within tolerance.

Signed-off-by: yboucher <yboucher@nvidia.com>
The dynamic program truncated each item weight with floor() to fit an integer
table while comparing the resulting minimum weight against the untruncated
capacity. Every truncated item bought the DP up to one unit of extra capacity,
and the separation capacity carries exactly one unit of margin (sum_j a_j minus
beta + 1), so a single truncated item is enough for the DP to return a set that
does not fit. The caller takes the complement of that set as a cover, and
sum_C a_j > beta then fails to hold, yielding an invalid cover inequality.

Carry the weights as f_t, as the lifting dynamic program alongside already
does, and assert the returned set respects the capacity.

Signed-off-by: yboucher <yboucher@nvidia.com>
is_minimal_cover only tests minimality, so nothing verified that the separated
set satisfies sum_C a_j > beta, which is what makes sum_C x_j <= |C| - 1 valid.
The final gate before the cut is added only tests violation by x*, so a set
that was not a cover produced a cut with no valid inequality behind it.

Assert the property where the set leaves the separator and where the
minimalization loop shrinks it, and drop the cut instead of emitting it if the
property does not hold.

Signed-off-by: yboucher <yboucher@nvidia.com>
Adds two test-only entry points alongside the existing clique and odd-cycle
ones: one for the rational scaling of a row, one that runs the knapsack
separator over a single row and returns the cut it emits.

The unit tests use a row whose two-decimal coefficients do not land on integers
when scaled, assert the scaled row is integral, and check the emitted cut
against every binary point of the row. The end-to-end test solves a five
variable model whose LP relaxation is uniquely fractional on that row, with
only knapsack cuts enabled; before the fix the separator fixed a variable that
is zero in the optimum and the solve reported 4 instead of 3.

Signed-off-by: yboucher <yboucher@nvidia.com>
The end-to-end regression exercises the separator through solve_mip, so the two
test-only functions in cuts.cpp and the unit tests over them are not worth the
surface they add to the cut interface.

Signed-off-by: yboucher <yboucher@nvidia.com>
@aliceb-nv aliceb-nv added this to the 26.10 milestone Aug 20, 2026
@aliceb-nv aliceb-nv added bug Something isn't working non-breaking Introduces a non-breaking change labels Aug 20, 2026
@copy-pr-bot

copy-pr-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Comment thread cpp/src/cuts/cuts.cpp
// dp(j, v) = minimum weight using first j items to get value v
dense_matrix_t<i_t, i_t> dp(n + 1, sum_value + 1, INT_INF);
// dp(j, v) = minimum weight using first j items to get value v.
// The weights are carried at full precision: rounding one down would let the DP return a set

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand why we need to use floats here. Where is the "rounding down" occurring that is causing the issue? And why does floats fix it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok. Reading further, it sounds like we tried to convert an inequality with floating point coefficients into a knapsack constraint with integer coefficients, but when we do this we can make some small error if we round down. Then when we do dynamic programming we can think we are under the weight limit, when we are actually over.

But I'm not sure the fix should be use floating point in the DP table. That can make the table a lot bigger. Maybe we should reject inequalities with large errors when converting to integer coefficients in our classification of knapsack constraints, so we don't arrive at this case in the first place? Or if this occurs when the error is quite small, maybe we just need to do a check at the end to see if the selected weights violate the capacity (similar to what is done in the assert now)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or just use ceil(weights[j-1]) instead of floor(weights[j-1])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think the typical solution here is careful rounding.

// The odd cycle over z1, z2, z3 makes z = (0.5, 0.5, 0.5), w = 0 the unique LP optimum, which
// puts y at 0.412078. Integrality moves the optimum to w = 1 with y = 0 and objective 3.
//
// The capacity coefficients are load bearing: scaling that row to integers multiplies by 100,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "load bearing" sounds like an AI generated phrase. Could we reword?

Comment thread cpp/src/cuts/cuts.cpp

rational_inequality.scale(scalar);

// The scaled product can land an ulp off the integer it represents. Callers rely on the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is a bit strange. First, the integral_tol used here, doesn't match our default integer_tol. It would probably be best to pass settings in and use settings.integer_tol. Unless, this should be a separate tolerance.

Second, I think this function is used by more than knapsack cuts. So it's a bit weird to reference knapsack cuts in this code. I think this code is just trying to generate an inequality with rational coefficients, whereas knapsack needs an inequality with integer coefficients. Maybe this code should be moved into a post-processing step, say in a function called integer_coefficients (that first calls rationalize_coefficients)

Finally, rounding introduces some error. That just multiplying by a scalar does not. So, we probably don't want to do this in all cases. Maybe we also want to track the total amount of error introduced (instead of just tracking the error on individual coefficients).

Comment thread cpp/src/cuts/cuts.cpp
// Take item j-1 if possible
if (v >= scaled_values[j - 1]) {
i_t candidate =
dp(j - 1, v - scaled_values[j - 1]) + static_cast<i_t>(std::floor(weights[j - 1]));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the floor is the big issue here. Because it causes us to underestimate the weight. Could the issue be solved by just using std::ceil(weights[j-1]) here? If weights[k] is an integer than ceil(weights[k]) == floor(weights[k]). But if it isn't exact, we will round up, overestimate how much weight we can carry, and thus still produce a valid solution.

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

Labels

bug Something isn't working non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants