From e190192999e8a7027554d4221ab512f1c9bc5118 Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 03:18:03 -0700 Subject: [PATCH 1/5] Reject non-integral rows in rational_coefficients 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 --- cpp/src/cuts/cuts.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cpp/src/cuts/cuts.cpp b/cpp/src/cuts/cuts.cpp index be45ffeecd..4dc2b3546e 100644 --- a/cpp/src/cuts/cuts.cpp +++ b/cpp/src/cuts/cuts.cpp @@ -4629,6 +4629,17 @@ bool rational_coefficients(const std::vector& var_types, rational_inequality.scale(scalar); + // The scaled product can land an ulp off the integer it represents. Callers rely on the + // integer-variable coefficients being exact integers: the knapsack cover test + // sum_C a_j > beta is only equivalent to sum_C a_j >= beta + 1 for integral a_j. + constexpr f_t integral_tol = 1e-6; + for (i_t k : indices) { + const f_t scaled = rational_inequality.vector.x[k]; + const f_t rounded = std::round(scaled); + if (std::abs(scaled - rounded) > integral_tol) { return false; } + rational_inequality.vector.x[k] = rounded; + } + return true; } From 5d796ba237358e00530c79fc7607e0600000c866 Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 03:21:56 -0700 Subject: [PATCH 2/5] Keep full precision weights in the knapsack separation DP 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 --- cpp/src/cuts/cuts.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/cpp/src/cuts/cuts.cpp b/cpp/src/cuts/cuts.cpp index 4dc2b3546e..5a13582296 100644 --- a/cpp/src/cuts/cuts.cpp +++ b/cpp/src/cuts/cuts.cpp @@ -2958,8 +2958,7 @@ f_t knapsack_generation_t::solve_knapsack_problem(const std::vector::max() / 2; + i_t sum_value = std::accumulate(scaled_values.begin(), scaled_values.end(), 0); if (verbose) { settings_.log.printf("sum value %d\n", sum_value); } const i_t max_size = 10000; if (sum_value <= 0.0 || sum_value >= max_size) { @@ -2972,10 +2971,12 @@ f_t knapsack_generation_t::solve_knapsack_problem(const std::vector 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 + // that violates the capacity, and the caller reads the complement of that set as a cover. + dense_matrix_t dp(n + 1, sum_value + 1, inf); dense_matrix_t take(n + 1, sum_value + 1, 0); - dp(0, 0) = 0; + dp(0, 0) = 0.0; // 4. Dynamic programming for (i_t j = 1; j <= n; ++j) { @@ -2985,8 +2986,7 @@ f_t knapsack_generation_t::solve_knapsack_problem(const std::vector= scaled_values[j - 1]) { - i_t candidate = - dp(j - 1, v - scaled_values[j - 1]) + static_cast(std::floor(weights[j - 1])); + f_t candidate = dp(j - 1, v - scaled_values[j - 1]) + weights[j - 1]; if (candidate < dp(j, v)) { dp(j, v) = candidate; take(j, v) = 1; @@ -3012,6 +3012,15 @@ f_t knapsack_generation_t::solve_knapsack_problem(const std::vector Date: Thu, 20 Aug 2026 03:23:41 -0700 Subject: [PATCH 3/5] Check the cover property before emitting a knapsack cut 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 --- cpp/src/cuts/cuts.cpp | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/cpp/src/cuts/cuts.cpp b/cpp/src/cuts/cuts.cpp index 5a13582296..ae6d71e78d 100644 --- a/cpp/src/cuts/cuts.cpp +++ b/cpp/src/cuts/cuts.cpp @@ -2406,6 +2406,7 @@ i_t knapsack_generation_t::generate_knapsack_cut( f_t objective_constant = 0.0; std::vector fixed_variables; std::vector fixed_values; + std::vector fixed_weights; const f_t x_tol = 1e-5; for (i_t k = 0; k < knapsack_inequality.size(); k++) { const i_t j = knapsack_inequality.index(k); @@ -2417,6 +2418,7 @@ i_t knapsack_generation_t::generate_knapsack_cut( // if xstar_j is close to 0, then we can fix z to zero fixed_variables.push_back(j); fixed_values.push_back(0.0); + fixed_weights.push_back(knapsack_inequality.vector.x[k]); seperation_rhs -= knapsack_inequality.vector.x[k]; // No need to adjust the objective constant continue; @@ -2425,6 +2427,7 @@ i_t knapsack_generation_t::generate_knapsack_cut( // if xstar_j is close to 1, then we can fix z to 1 fixed_variables.push_back(j); fixed_values.push_back(1.0); + fixed_weights.push_back(knapsack_inequality.vector.x[k]); // Note seperation rhs is unchanged objective_constant += vj; continue; @@ -2466,12 +2469,28 @@ i_t knapsack_generation_t::generate_knapsack_cut( return -1; } - i_t cover_size = 0; + i_t cover_size = 0; + f_t cover_weight = 0.0; for (i_t k = 0; k < solution.size(); k++) { - if (solution[k] == 0.0) { cover_size++; } + if (solution[k] == 0.0) { + cover_size++; + cover_weight += weights[k]; + } } for (i_t k = 0; k < fixed_values.size(); k++) { - if (fixed_values[k] == 1.0) { cover_size++; } + if (fixed_values[k] == 1.0) { + cover_size++; + cover_weight += fixed_weights[k]; + } + } + + // sum_{j in C} a_j > beta is what makes sum_{j in C} x_j <= |C| - 1 valid. The coefficients are + // integral here, so demand a full unit rather than letting rounding in the sums decide. + const bool is_cover = cover_weight >= knapsack_inequality.rhs + 1.0 - tol; + cuopt_assert(is_cover, "knapsack separation produced a set that is not a cover"); + if (!is_cover) { + restore_complemented(complemented_variables); + return -1; } cut.reserve(cover_size); @@ -2641,6 +2660,9 @@ void knapsack_generation_t::minimal_cover_and_partition( } } + cuopt_assert(cover_sum >= beta + 1.0 - 1e-6, + "minimal cover reduction dropped an item the cover needed"); + // Go through and correct cover_indicies and cover_coefficients for (i_t k = 0; k < cover_coefficients.size();) { if (cover_coefficients[k] == 0.0) { From 440e3a08cc583f548ac3b1149285e829880fd09e Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 03:36:19 -0700 Subject: [PATCH 4/5] Add knapsack cover regression tests 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 --- cpp/src/cuts/cuts.cpp | 88 +++++++++++++++++++++++++++ cpp/src/cuts/cuts.hpp | 26 ++++++++ cpp/tests/mip/cuts_test.cu | 118 +++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) diff --git a/cpp/src/cuts/cuts.cpp b/cpp/src/cuts/cuts.cpp index ae6d71e78d..1ebacb506f 100644 --- a/cpp/src/cuts/cuts.cpp +++ b/cpp/src/cuts/cuts.cpp @@ -1124,6 +1124,94 @@ std::vector> find_violated_odd_cycles_for_test( return result; } +// This function is only used in tests +rational_row_for_test_t rational_coefficients_for_test(const std::vector& coefficients, + double rhs) +{ + const int num_cols = static_cast(coefficients.size()); + inequality_t row(num_cols); + for (int j = 0; j < num_cols; j++) { + row.push_back(j, coefficients[j]); + } + row.rhs = rhs; + + const std::vector var_types(num_cols, variable_type_t::INTEGER); + inequality_t scaled_row = row; + + rational_row_for_test_t result; + result.ok = rational_coefficients(var_types, row, scaled_row); + if (!result.ok) { return result; } + + result.coefficients.reserve(scaled_row.size()); + for (int k = 0; k < static_cast(scaled_row.size()); k++) { + result.coefficients.push_back(scaled_row.coeff(k)); + } + result.rhs = scaled_row.rhs; + return result; +} + +// This function is only used in tests +knapsack_cut_for_test_t generate_knapsack_cut_for_test(const std::vector& row_coefficients, + double row_rhs, + const std::vector& x_relax) +{ + cuopt_assert(x_relax.size() == row_coefficients.size(), + "x_relax size mismatch in knapsack test helper"); + + const int num_binaries = static_cast(row_coefficients.size()); + const int slack_col = num_binaries; + const int num_cols = num_binaries + 1; + + lp_problem_t lp(nullptr, 1, num_cols, num_cols); + lp.rhs[0] = row_rhs; + for (int j = 0; j < num_binaries; j++) { + lp.lower[j] = 0.0; + lp.upper[j] = 1.0; + } + lp.lower[slack_col] = 0.0; + lp.upper[slack_col] = inf; + + sparse_vector_t row(num_cols, 0); + for (int j = 0; j < num_binaries; j++) { + row.i.push_back(j); + row.x.push_back(row_coefficients[j]); + } + row.i.push_back(slack_col); + row.x.push_back(1.0); + + csr_matrix_t Arow(0, num_cols, 0); + Arow.append_row(row); + + std::vector var_types(num_binaries, variable_type_t::INTEGER); + var_types.push_back(variable_type_t::CONTINUOUS); + const std::vector new_slacks{slack_col}; + + std::vector xstar = x_relax; + xstar.push_back(0.0); + + const simplex_solver_settings_t settings; + knapsack_generation_t knapsack_generation(lp, settings, Arow, new_slacks, var_types); + + knapsack_cut_for_test_t result; + if (knapsack_generation.num_knapsack_constraints() == 0) { return result; } + + inequality_t cut(num_cols); + if (knapsack_generation.generate_knapsack_cut( + lp, settings, Arow, new_slacks, var_types, xstar, 0, cut) != 0) { + return result; + } + + result.found = true; + result.indices.reserve(cut.size()); + result.coefficients.reserve(cut.size()); + for (int k = 0; k < static_cast(cut.size()); k++) { + result.indices.push_back(cut.index(k)); + result.coefficients.push_back(cut.coeff(k)); + } + result.rhs = cut.rhs; + return result; +} + namespace { // 64-bit integer mixer (SplitMix64). Used as the building block for the diff --git a/cpp/src/cuts/cuts.hpp b/cpp/src/cuts/cuts.hpp index 78091c85f6..2676b68283 100644 --- a/cpp/src/cuts/cuts.hpp +++ b/cpp/src/cuts/cuts.hpp @@ -286,6 +286,32 @@ std::vector> find_violated_odd_cycles_for_test( double min_violation, double time_limit); +struct rational_row_for_test_t { + bool ok{false}; + std::vector coefficients; + double rhs{0.0}; +}; + +// Test-only helper to run the production rational scaling used by the cut separators on a +// dense row over integer variables. +rational_row_for_test_t rational_coefficients_for_test(const std::vector& coefficients, + double rhs); + +struct knapsack_cut_for_test_t { + bool found{false}; + std::vector indices; + std::vector coefficients; + double rhs{0.0}; +}; + +// Test-only helper to run the production knapsack separator on the single row +// sum_j row_coefficients[j] x_j <= row_rhs over binaries, with a non-negative slack appended. +// The returned cut is in the >= form the cut pool stores. found is false when the row is not +// treated as a knapsack row or no cut is separated at x_relax. +knapsack_cut_for_test_t generate_knapsack_cut_for_test(const std::vector& row_coefficients, + double row_rhs, + const std::vector& x_relax); + template class cut_pool_t { public: diff --git a/cpp/tests/mip/cuts_test.cu b/cpp/tests/mip/cuts_test.cu index b4fc3e8cc7..714d516bc5 100644 --- a/cpp/tests/mip/cuts_test.cu +++ b/cpp/tests/mip/cuts_test.cu @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -442,6 +443,19 @@ void disable_all_cuts(mip_solver_settings_t& settings) settings.strong_chvatal_gomory_cuts = 0; } +void disable_non_knapsack_cuts(mip_solver_settings_t& settings) +{ + settings.max_cut_passes = 10; + settings.knapsack_cuts = 1; + settings.clique_cuts = 0; + settings.zero_half_cuts = 0; + settings.mixed_integer_gomory_cuts = 0; + settings.mir_cuts = 0; + settings.strong_chvatal_gomory_cuts = 0; + settings.flow_cover_cuts = 0; + settings.implied_bound_cuts = 0; +} + bool cut_is_invalid_for_incumbent(const std::vector& cut_vars, const std::vector& incumbent, double tol) @@ -934,6 +948,110 @@ TEST(cuts, test_cuts_2) EXPECT_EQ(solution.get_num_nodes(), 0); } +// Scaling this row to integers multiplies by 100, and the products do not land on integers: +// 135.45 becomes 13544.999999999998 and 135.42 becomes 13541.999999999998. The knapsack +// separator needs the scaled row to be integral, so the coefficients matter here. +std::vector truncating_knapsack_row() { return {-450.0, 135.45, 135.42, 100.0}; } + +// Unique optimum of the LP relaxation of create_knapsack_cover_floor_problem, restricted to the +// columns of the row above. +std::vector truncating_knapsack_row_relaxation() { return {0.412078, 0.5, 0.5, 0.5}; } + +TEST(cuts, knapsack_rational_scaling_is_integral) +{ + const auto row = truncating_knapsack_row(); + const auto scaled = mip::rational_coefficients_for_test(row, 0.0); + ASSERT_TRUE(scaled.ok); + ASSERT_EQ(scaled.coefficients.size(), row.size()); + for (const double coefficient : scaled.coefficients) { + EXPECT_DOUBLE_EQ(coefficient, std::round(coefficient)); + } +} + +TEST(cuts, knapsack_cover_cut_is_valid) +{ + constexpr double row_rhs = 0.0; + constexpr double tol = 1e-9; + const auto row = truncating_knapsack_row(); + const int num_binaries = static_cast(row.size()); + + const auto cut = + mip::generate_knapsack_cut_for_test(row, row_rhs, truncating_knapsack_row_relaxation()); + if (!cut.found) { GTEST_SKIP() << "no knapsack cut separated for this row"; } + + for (const int j : cut.indices) { + ASSERT_LT(j, num_binaries) << "knapsack cut must not reference the slack column"; + } + + // The cut is stored as sum_j d_j x_j >= rhs and must hold at every binary point of the row. + for (int assignment = 0; assignment < (1 << num_binaries); assignment++) { + double row_activity = 0.0; + for (int j = 0; j < num_binaries; j++) { + if (((assignment >> j) & 1) != 0) { row_activity += row[j]; } + } + if (row_activity > row_rhs + tol) { continue; } + + double cut_activity = 0.0; + for (size_t k = 0; k < cut.indices.size(); k++) { + if (((assignment >> cut.indices[k]) & 1) != 0) { cut_activity += cut.coefficients[k]; } + } + EXPECT_GE(cut_activity, cut.rhs - tol) + << "knapsack cut cuts off the feasible binary point " << assignment; + } +} + +io::mps_data_model_t create_knapsack_cover_floor_problem() +{ + // 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. + return cuopt::test::parse_inline_lp(R"LP( +Minimize + obj: 2 y + z1 + z2 + z3 + 3 w +Subject To + capacity: -450 y + 135.45 z1 + 135.42 z2 + 100 z3 <= 0 + tri12: z1 + z2 + w >= 1 + tri13: z1 + z3 + w >= 1 + tri23: z2 + z3 + w >= 1 +Binaries + y + z1 + z2 + z3 + w +End +)LP"); +} + +TEST(cuts, knapsack_cover_floor_regression) +{ + const raft::handle_t handle_{}; + auto problem = create_knapsack_cover_floor_problem(); + + mip_solver_settings_t settings; + settings.time_limit = 10.; + disable_non_knapsack_cuts(settings); + settings.presolver = presolver_t::None; + + mip_solution_t solution = solve_mip(&handle_, problem, settings); + EXPECT_EQ(solution.get_termination_status(), mip_termination_status_t::Optimal); + EXPECT_NEAR(3.0, solution.get_objective_value(), 1e-6); +} + +TEST(cuts, knapsack_cover_floor_regression_reference) +{ + const raft::handle_t handle_{}; + auto problem = create_knapsack_cover_floor_problem(); + + mip_solver_settings_t settings; + settings.time_limit = 10.; + disable_all_cuts(settings); + settings.presolver = presolver_t::None; + + mip_solution_t solution = solve_mip(&handle_, problem, settings); + EXPECT_EQ(solution.get_termination_status(), mip_termination_status_t::Optimal); + EXPECT_NEAR(3.0, solution.get_objective_value(), 1e-6); +} + TEST(cuts, test_duplicate_cuts_detection) { simplex::simplex_solver_settings_t settings; From df841794b10266336153d7894f1e5f09385efeaa Mon Sep 17 00:00:00 2001 From: yboucher Date: Thu, 20 Aug 2026 03:47:31 -0700 Subject: [PATCH 5/5] Drop the knapsack test-only entry points 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 --- cpp/src/cuts/cuts.cpp | 88 -------------------------------------- cpp/src/cuts/cuts.hpp | 26 ----------- cpp/tests/mip/cuts_test.cu | 57 ++---------------------- 3 files changed, 4 insertions(+), 167 deletions(-) diff --git a/cpp/src/cuts/cuts.cpp b/cpp/src/cuts/cuts.cpp index 1ebacb506f..ae6d71e78d 100644 --- a/cpp/src/cuts/cuts.cpp +++ b/cpp/src/cuts/cuts.cpp @@ -1124,94 +1124,6 @@ std::vector> find_violated_odd_cycles_for_test( return result; } -// This function is only used in tests -rational_row_for_test_t rational_coefficients_for_test(const std::vector& coefficients, - double rhs) -{ - const int num_cols = static_cast(coefficients.size()); - inequality_t row(num_cols); - for (int j = 0; j < num_cols; j++) { - row.push_back(j, coefficients[j]); - } - row.rhs = rhs; - - const std::vector var_types(num_cols, variable_type_t::INTEGER); - inequality_t scaled_row = row; - - rational_row_for_test_t result; - result.ok = rational_coefficients(var_types, row, scaled_row); - if (!result.ok) { return result; } - - result.coefficients.reserve(scaled_row.size()); - for (int k = 0; k < static_cast(scaled_row.size()); k++) { - result.coefficients.push_back(scaled_row.coeff(k)); - } - result.rhs = scaled_row.rhs; - return result; -} - -// This function is only used in tests -knapsack_cut_for_test_t generate_knapsack_cut_for_test(const std::vector& row_coefficients, - double row_rhs, - const std::vector& x_relax) -{ - cuopt_assert(x_relax.size() == row_coefficients.size(), - "x_relax size mismatch in knapsack test helper"); - - const int num_binaries = static_cast(row_coefficients.size()); - const int slack_col = num_binaries; - const int num_cols = num_binaries + 1; - - lp_problem_t lp(nullptr, 1, num_cols, num_cols); - lp.rhs[0] = row_rhs; - for (int j = 0; j < num_binaries; j++) { - lp.lower[j] = 0.0; - lp.upper[j] = 1.0; - } - lp.lower[slack_col] = 0.0; - lp.upper[slack_col] = inf; - - sparse_vector_t row(num_cols, 0); - for (int j = 0; j < num_binaries; j++) { - row.i.push_back(j); - row.x.push_back(row_coefficients[j]); - } - row.i.push_back(slack_col); - row.x.push_back(1.0); - - csr_matrix_t Arow(0, num_cols, 0); - Arow.append_row(row); - - std::vector var_types(num_binaries, variable_type_t::INTEGER); - var_types.push_back(variable_type_t::CONTINUOUS); - const std::vector new_slacks{slack_col}; - - std::vector xstar = x_relax; - xstar.push_back(0.0); - - const simplex_solver_settings_t settings; - knapsack_generation_t knapsack_generation(lp, settings, Arow, new_slacks, var_types); - - knapsack_cut_for_test_t result; - if (knapsack_generation.num_knapsack_constraints() == 0) { return result; } - - inequality_t cut(num_cols); - if (knapsack_generation.generate_knapsack_cut( - lp, settings, Arow, new_slacks, var_types, xstar, 0, cut) != 0) { - return result; - } - - result.found = true; - result.indices.reserve(cut.size()); - result.coefficients.reserve(cut.size()); - for (int k = 0; k < static_cast(cut.size()); k++) { - result.indices.push_back(cut.index(k)); - result.coefficients.push_back(cut.coeff(k)); - } - result.rhs = cut.rhs; - return result; -} - namespace { // 64-bit integer mixer (SplitMix64). Used as the building block for the diff --git a/cpp/src/cuts/cuts.hpp b/cpp/src/cuts/cuts.hpp index 2676b68283..78091c85f6 100644 --- a/cpp/src/cuts/cuts.hpp +++ b/cpp/src/cuts/cuts.hpp @@ -286,32 +286,6 @@ std::vector> find_violated_odd_cycles_for_test( double min_violation, double time_limit); -struct rational_row_for_test_t { - bool ok{false}; - std::vector coefficients; - double rhs{0.0}; -}; - -// Test-only helper to run the production rational scaling used by the cut separators on a -// dense row over integer variables. -rational_row_for_test_t rational_coefficients_for_test(const std::vector& coefficients, - double rhs); - -struct knapsack_cut_for_test_t { - bool found{false}; - std::vector indices; - std::vector coefficients; - double rhs{0.0}; -}; - -// Test-only helper to run the production knapsack separator on the single row -// sum_j row_coefficients[j] x_j <= row_rhs over binaries, with a non-negative slack appended. -// The returned cut is in the >= form the cut pool stores. found is false when the row is not -// treated as a knapsack row or no cut is separated at x_relax. -knapsack_cut_for_test_t generate_knapsack_cut_for_test(const std::vector& row_coefficients, - double row_rhs, - const std::vector& x_relax); - template class cut_pool_t { public: diff --git a/cpp/tests/mip/cuts_test.cu b/cpp/tests/mip/cuts_test.cu index 714d516bc5..88c553bcf1 100644 --- a/cpp/tests/mip/cuts_test.cu +++ b/cpp/tests/mip/cuts_test.cu @@ -27,7 +27,6 @@ #include #include -#include #include #include #include @@ -948,62 +947,14 @@ TEST(cuts, test_cuts_2) EXPECT_EQ(solution.get_num_nodes(), 0); } -// Scaling this row to integers multiplies by 100, and the products do not land on integers: -// 135.45 becomes 13544.999999999998 and 135.42 becomes 13541.999999999998. The knapsack -// separator needs the scaled row to be integral, so the coefficients matter here. -std::vector truncating_knapsack_row() { return {-450.0, 135.45, 135.42, 100.0}; } - -// Unique optimum of the LP relaxation of create_knapsack_cover_floor_problem, restricted to the -// columns of the row above. -std::vector truncating_knapsack_row_relaxation() { return {0.412078, 0.5, 0.5, 0.5}; } - -TEST(cuts, knapsack_rational_scaling_is_integral) -{ - const auto row = truncating_knapsack_row(); - const auto scaled = mip::rational_coefficients_for_test(row, 0.0); - ASSERT_TRUE(scaled.ok); - ASSERT_EQ(scaled.coefficients.size(), row.size()); - for (const double coefficient : scaled.coefficients) { - EXPECT_DOUBLE_EQ(coefficient, std::round(coefficient)); - } -} - -TEST(cuts, knapsack_cover_cut_is_valid) -{ - constexpr double row_rhs = 0.0; - constexpr double tol = 1e-9; - const auto row = truncating_knapsack_row(); - const int num_binaries = static_cast(row.size()); - - const auto cut = - mip::generate_knapsack_cut_for_test(row, row_rhs, truncating_knapsack_row_relaxation()); - if (!cut.found) { GTEST_SKIP() << "no knapsack cut separated for this row"; } - - for (const int j : cut.indices) { - ASSERT_LT(j, num_binaries) << "knapsack cut must not reference the slack column"; - } - - // The cut is stored as sum_j d_j x_j >= rhs and must hold at every binary point of the row. - for (int assignment = 0; assignment < (1 << num_binaries); assignment++) { - double row_activity = 0.0; - for (int j = 0; j < num_binaries; j++) { - if (((assignment >> j) & 1) != 0) { row_activity += row[j]; } - } - if (row_activity > row_rhs + tol) { continue; } - - double cut_activity = 0.0; - for (size_t k = 0; k < cut.indices.size(); k++) { - if (((assignment >> cut.indices[k]) & 1) != 0) { cut_activity += cut.coefficients[k]; } - } - EXPECT_GE(cut_activity, cut.rhs - tol) - << "knapsack cut cuts off the feasible binary point " << assignment; - } -} - io::mps_data_model_t create_knapsack_cover_floor_problem() { // 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, + // and 135.45 and 135.42 do not land on integers when scaled, which is what the knapsack + // separator needs them to do. return cuopt::test::parse_inline_lp(R"LP( Minimize obj: 2 y + z1 + z2 + z3 + 3 w