// Copyright 2010-2025 Google LLC // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #include "ortools/pdlp/quadratic_program.h" #include #include #include #include #include #include #include "Eigen/Core" #include "Eigen/SparseCore" #include "absl/status/status.h" #include "absl/status/statusor.h" #include "gtest/gtest.h" #include "ortools/base/gmock.h" #include "ortools/base/parse_text_proto.h" #include "ortools/linear_solver/linear_solver.pb.h" #include "ortools/pdlp/test_util.h" namespace operations_research::pdlp { namespace { using ::google::protobuf::contrib::parse_proto::ParseTextOrDie; using ::operations_research::pdlp::internal::CombineRepeatedTripletsInPlace; using ::testing::ElementsAre; using ::testing::EndsWith; using ::testing::Eq; using ::testing::EqualsProto; using ::testing::HasSubstr; using ::testing::IsEmpty; using ::testing::Optional; using ::testing::PrintToString; using ::testing::SizeIs; using ::testing::StartsWith; using ::testing::StrEq; using ::testing::status::IsOkAndHolds; const double kInfinity = std::numeric_limits::infinity(); TEST(QuadraticProgram, DefaultConstructorWorks) { QuadraticProgram qp; } TEST(QuadraticProgram, MoveConstructor) { QuadraticProgram qp1 = TestDiagonalQp1(); QuadraticProgram qp2(std::move(qp1)); VerifyTestDiagonalQp1(qp2); } TEST(QuadraticProgram, MoveAssignment) { QuadraticProgram qp1 = TestDiagonalQp1(); QuadraticProgram qp2; qp2 = std::move(qp1); VerifyTestDiagonalQp1(qp2); } TEST(ValidateQuadraticProgramDimensions, ValidProblem) { const absl::Status status = ValidateQuadraticProgramDimensions(TestDiagonalQp1()); EXPECT_TRUE(status.ok()) << status; } TEST(ValidateQuadraticProgramDimensions, ValidProblemWithNames) { QuadraticProgram qp = TestDiagonalQp1(); qp.variable_names = {"x0", "x1"}; qp.constraint_names = {"c0"}; const absl::Status status = ValidateQuadraticProgramDimensions(TestDiagonalQp1()); EXPECT_TRUE(status.ok()) << status; } TEST(ValidateQuadraticProgramDimensions, ConstraintLowerBoundsInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.constraint_lower_bounds.resize(10); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, ConstraintUpperBoundsInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.constraint_upper_bounds.resize(10); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, ObjectiveVectorInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.objective_vector.resize(10); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, VariableLowerBoundsInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.variable_lower_bounds.resize(10); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, VariableUpperBoundsInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.variable_upper_bounds.resize(10); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, ConstraintMatrixRowsInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.constraint_matrix.resize(10, 2); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, ConstraintMatrixColsInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.constraint_matrix.resize(2, 10); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, ObjectiveMatrixRowsInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.objective_matrix.emplace(); qp.objective_matrix->resize(10); EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, VariableNamesInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.variable_names = {"x0"}; EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } TEST(ValidateQuadraticProgramDimensions, ConstraintNamesInconsistent) { QuadraticProgram qp; qp.ResizeAndInitialize(/*num_variables=*/2, /*num_constraints=*/3); qp.constraint_names = {"c0"}; EXPECT_EQ(ValidateQuadraticProgramDimensions(qp).code(), absl::StatusCode::kInvalidArgument); } class ConvertQpMpModelProtoTest : public testing::TestWithParam {}; // The LP: // optimize 5.5 x_0 + 2 x_1 - x_2 + x_3 - 14 s.t. // 2 x_0 + x_1 + x_2 + 2 x_3 = 12 // x_0 + x_2 >= 7 // 3.5 x_0 <= -4 // -1 <= 1.5 x_2 - x_3 <= 1 // -infinity <= x_0 <= infinity // -2 <= x_1 <= infinity // -infinity <= x_2 <= 6 // 2.5 <= x_3 <= 3.5 MPModelProto TestLpProto(bool maximize) { auto proto = ParseTextOrDie(R"pb(variable { lower_bound: -inf upper_bound: inf objective_coefficient: 5.5 } variable { lower_bound: -2 upper_bound: inf objective_coefficient: -2 } variable { lower_bound: -inf upper_bound: 6 objective_coefficient: -1 } variable { lower_bound: 2.5 upper_bound: 3.5 objective_coefficient: 1 } constraint { lower_bound: 12 upper_bound: 12 var_index: [ 0, 1, 2, 3 ] coefficient: [ 2, 1, 1, 2 ] } constraint { lower_bound: -inf upper_bound: 7 var_index: [ 0, 2 ] coefficient: [ 1, 1 ] } constraint { lower_bound: -4 upper_bound: inf var_index: [ 0 ] coefficient: [ 4 ] } constraint { lower_bound: -1 upper_bound: 1 var_index: [ 2, 3 ] coefficient: [ 1.5, -1 ] } objective_offset: -14)pb"); proto.set_maximize(maximize); return proto; } // This is tested for both minimization and maximization. TEST_P(ConvertQpMpModelProtoTest, LpFromMpModelProto) { const bool maximize = GetParam(); MPModelProto proto = TestLpProto(maximize); const auto lp = QpFromMpModelProto(proto, /*relax_integer_variables=*/false); ASSERT_TRUE(lp.ok()) << lp.status(); VerifyTestLp(*lp, maximize); } TEST_P(ConvertQpMpModelProtoTest, LpToMpModelProto) { const bool maximize = GetParam(); QuadraticProgram lp = TestLp(); if (maximize) { lp.objective_scaling_factor = -1; lp.objective_vector *= -1; lp.objective_offset *= -1; } EXPECT_THAT(QpToMpModelProto(lp), IsOkAndHolds(EqualsProto(TestLpProto(maximize)))); } TEST_P(ConvertQpMpModelProtoTest, LpRoundTrip) { const bool maximize = GetParam(); ASSERT_OK_AND_ASSIGN(QuadraticProgram qp, QpFromMpModelProto(TestLpProto(maximize), /*relax_integer_variables=*/false)); EXPECT_THAT(QpToMpModelProto(qp), IsOkAndHolds(EqualsProto(TestLpProto(maximize)))); } // The QP: // optimize x_0^2 + x_1^2 + 3 x_0 - 4 s.t. // x_0 + x_1 <= 42 // -1 <= x_0 <= 2 // -2 <= x_1 <= 3 MPModelProto TestQpProto(bool maximize) { auto proto = ParseTextOrDie( R"pb(variable { lower_bound: -1 upper_bound: 2 objective_coefficient: 3 } variable { lower_bound: -2 upper_bound: 3 objective_coefficient: 0 } constraint { lower_bound: -inf upper_bound: 42 var_index: [ 0, 1 ] coefficient: [ 1, 1 ] } objective_offset: -4 quadratic_objective { qvar1_index: [ 0, 1 ] qvar2_index: [ 0, 1 ] coefficient: [ 1, 1 ] } )pb"); proto.set_maximize(maximize); return proto; } // This is tested for both minimization and maximization. TEST_P(ConvertQpMpModelProtoTest, QpFromMpModelProto) { const bool maximize = GetParam(); MPModelProto proto = TestQpProto(maximize); const auto qp = QpFromMpModelProto(proto, /*relax_integer_variables=*/false); ASSERT_TRUE(qp.ok()) << qp.status(); EXPECT_THAT(qp->constraint_lower_bounds, ElementsAre(-kInfinity)); EXPECT_THAT(qp->constraint_upper_bounds, ElementsAre(42)); EXPECT_THAT(qp->variable_lower_bounds, ElementsAre(-1, -2)); EXPECT_THAT(qp->variable_upper_bounds, ElementsAre(2, 3)); EXPECT_THAT(ToDense(qp->constraint_matrix), EigenArrayEq({{1, 1}})); EXPECT_TRUE(qp->constraint_matrix.isCompressed()); double sign = maximize ? -1 : 1; EXPECT_EQ(sign * qp->objective_offset, -4); EXPECT_EQ(qp->objective_scaling_factor, sign); EXPECT_THAT(sign * qp->objective_vector, ElementsAre(3, 0)); EXPECT_THAT(sign * (qp->objective_matrix->diagonal()), EigenArrayEq({2, 2})); } TEST(QpFromMpModelProto, ErrorsOnOffDiagonalTerms) { auto proto = ParseTextOrDie( R"pb(variable { lower_bound: -1 upper_bound: 2 objective_coefficient: 3 } variable { lower_bound: -2 upper_bound: 3 objective_coefficient: 0 } constraint { lower_bound: -inf upper_bound: 42 var_index: [ 0, 1 ] coefficient: [ 1, 1 ] } objective_offset: -4 quadratic_objective { qvar1_index: [ 0 ] qvar2_index: [ 1 ] coefficient: [ 1 ] } )pb"); EXPECT_EQ(QpFromMpModelProto(proto, /*relax_integer_variables=*/false) .status() .code(), absl::StatusCode::kInvalidArgument); } TEST(CanFitInMpModelProto, SmallQpOk) { // `QpFromMpModelProtoTest` verifies that `qp` is as expected. const auto qp = QpFromMpModelProto(TestQpProto(/*maximize=*/false), /*relax_integer_variables=*/false); ASSERT_TRUE(qp.ok()) << qp.status(); EXPECT_TRUE(CanFitInMpModelProto(*qp).ok()); } TEST(CanFitInMpModelProto, TooManyVariablesFails) { QuadraticProgram qp(1024, 5); EXPECT_THAT(internal::TestableCanFitInMpModelProto(qp, 1023), testing::status::StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("variable"))); } TEST(CanFitInMpModelProto, TooManyConstraintsFails) { QuadraticProgram qp(3, 1024); EXPECT_THAT(internal::TestableCanFitInMpModelProto(qp, 1023), testing::status::StatusIs(absl::StatusCode::kInvalidArgument, HasSubstr("constraint"))); } TEST_P(ConvertQpMpModelProtoTest, QpRoundTrip) { const bool maximize = GetParam(); ASSERT_OK_AND_ASSIGN(QuadraticProgram qp, QpFromMpModelProto(TestQpProto(maximize), /*relax_integer_variables=*/false)); EXPECT_THAT(QpToMpModelProto(qp), IsOkAndHolds(EqualsProto(TestQpProto(maximize)))); } // The ILP: // optimize x_0 + 2 * x_1 s.t. // x_0 + x_1 <= 1 // -1 <= x_0 <= 2 // -2 <= x_1 <= 3 // x_1 integer // This is tested for both minimization and maximization. TEST_P(ConvertQpMpModelProtoTest, IntegerVariablesFromMpModelProto) { const bool maximize = GetParam(); auto proto = ParseTextOrDie( R"pb(variable { lower_bound: -1 upper_bound: 2 objective_coefficient: 1 } variable { lower_bound: -2 upper_bound: 3 objective_coefficient: 2 is_integer: true } constraint { lower_bound: -inf upper_bound: 1 var_index: [ 0, 1 ] coefficient: [ 1, 1 ] } )pb"); proto.set_maximize(maximize); EXPECT_EQ(QpFromMpModelProto(proto, /*relax_integer_variables=*/false) .status() .code(), absl::StatusCode::kInvalidArgument); const auto lp = QpFromMpModelProto(proto, /*relax_integer_variables=*/true); ASSERT_TRUE(lp.ok()) << lp.status(); EXPECT_THAT(lp->constraint_lower_bounds, ElementsAre(-kInfinity)); EXPECT_THAT(lp->constraint_upper_bounds, ElementsAre(1)); EXPECT_THAT(lp->variable_lower_bounds, ElementsAre(-1, -2)); EXPECT_THAT(lp->variable_upper_bounds, ElementsAre(2, 3)); EXPECT_THAT(ToDense(lp->constraint_matrix), EigenArrayEq({{1, 1}})); EXPECT_TRUE(lp->constraint_matrix.isCompressed()); double sign = maximize ? -1 : 1; EXPECT_EQ(lp->objective_offset, 0); EXPECT_THAT(sign * lp->objective_vector, ElementsAre(1, 2)); EXPECT_FALSE(lp->objective_matrix.has_value()); } MPModelProto TinyModelWithNames() { return ParseTextOrDie( R"pb(name: "problem" variable { name: "x_0" lower_bound: -1 upper_bound: 2 objective_coefficient: 1 } variable { name: "x_1" lower_bound: -2 upper_bound: 3 objective_coefficient: 2 } constraint { name: "c_0" lower_bound: -inf upper_bound: 1 var_index: [ 0, 1 ] coefficient: [ 1, 1 ] } )pb"); } TEST(QpFromMpModelProtoTest, EmptyQp) { MPModelProto proto; const auto qp = QpFromMpModelProto(proto, /*relax_integer_variables=*/false); ASSERT_TRUE(qp.ok()) << qp.status(); EXPECT_THAT(qp->constraint_lower_bounds, ElementsAre()); EXPECT_THAT(qp->constraint_upper_bounds, ElementsAre()); EXPECT_THAT(qp->variable_lower_bounds, ElementsAre()); EXPECT_THAT(qp->variable_upper_bounds, ElementsAre()); EXPECT_EQ(qp->constraint_matrix.cols(), 0); EXPECT_EQ(qp->constraint_matrix.rows(), 0); EXPECT_EQ(qp->objective_offset, 0); EXPECT_EQ(qp->objective_scaling_factor, 1); EXPECT_FALSE(qp->objective_matrix.has_value()); EXPECT_THAT(qp->objective_vector, ElementsAre()); } TEST(QpFromMpModelProtoTest, DoesNotIncludeNames) { const auto lp = QpFromMpModelProto(TinyModelWithNames(), /*relax_integer_variables=*/true, /*include_names=*/false); ASSERT_TRUE(lp.ok()) << lp.status(); EXPECT_EQ(lp->problem_name, std::nullopt); EXPECT_EQ(lp->variable_names, std::nullopt); EXPECT_EQ(lp->constraint_names, std::nullopt); } TEST(QpFromMpModelProtoTest, IncludesNames) { const auto lp = QpFromMpModelProto(TinyModelWithNames(), /*relax_integer_variables=*/true, /*include_names=*/true); ASSERT_TRUE(lp.ok()) << lp.status(); EXPECT_THAT(lp->problem_name, Optional(Eq("problem"))); EXPECT_THAT(lp->variable_names, Optional(ElementsAre("x_0", "x_1"))); EXPECT_THAT(lp->constraint_names, Optional(ElementsAre("c_0"))); } INSTANTIATE_TEST_SUITE_P( ConvertQpMpModelProtoTests, ConvertQpMpModelProtoTest, testing::Bool(), [](const testing::TestParamInfo& info) { if (info.param) { return "maximize"; } else { return "minimize"; } }); TEST(QuadraticProgramToStringTest, TestLpIsCorrect) { QuadraticProgram qp = TestLp(); EXPECT_THAT(ToString(qp), StrEq("minimize 1 * (-14 + 5.5 x0 + -2 x1 + -1 x2 + 1 x3)\n" "c0: 12 <= + 2 x0 + 1 x1 + 1 x2 + 2 x3 <= 12\n" "c1: + 1 x0 + 1 x2 <= 7\n" "c2: -4 <= + 4 x0\n" "c3: -1 <= + 1.5 x2 + -1 x3 <= 1\n" "Bounds\n" "x0 free\n" "x1 >= -2\n" "x2 <= 6\n" "2.5 <= x3 <= 3.5\n")); } TEST(QuadraticProgramToStringTest, TestLpIsCorrectWithMaximization) { QuadraticProgram qp = TestLp(); qp.objective_scaling_factor = -1; EXPECT_THAT(ToString(qp), StrEq("maximize -1 * (-14 + 5.5 x0 + -2 x1 + -1 x2 + 1 x3)\n" "c0: 12 <= + 2 x0 + 1 x1 + 1 x2 + 2 x3 <= 12\n" "c1: + 1 x0 + 1 x2 <= 7\n" "c2: -4 <= + 4 x0\n" "c3: -1 <= + 1.5 x2 + -1 x3 <= 1\n" "Bounds\n" "x0 free\n" "x1 >= -2\n" "x2 <= 6\n" "2.5 <= x3 <= 3.5\n")); } TEST(QuadraticProgramToStringTest, TestLpTruncatesCorrectly) { QuadraticProgram qp = TestLp(); EXPECT_THAT(ToString(qp, 100), AllOf(SizeIs(100), EndsWith("...\n"), StrEq("minimize 1 * (-14 + 5.5 x0 + -2 x1 + -1 x2 + 1 x3)\n" "c0: 12 <= + 2 x0 + 1 x1 + 1 x2 + 2 x3 <= 12\n" "c...\n"))); } TEST(QuadraticProgramToStringTest, TestDiagonalQp1IsCorrect) { QuadraticProgram qp = TestDiagonalQp1(); EXPECT_THAT( ToString(qp), StrEq("minimize 1 * (5 + -1 x0 + -1 x1 + 1/2 * ( + 4 x0^2 + 1 x1^2))\n" "c0: + 1 x0 + 1 x1 <= 1\n" "Bounds\n" "1 <= x0 <= 2\n" "-2 <= x1 <= 4\n")); } TEST(QuadraticProgramToStringTest, UsesVariableAndConstraintNames) { QuadraticProgram qp = TestDiagonalQp1(); qp.problem_name = "test"; qp.variable_names = {"x", "y"}; qp.constraint_names = {"total"}; EXPECT_THAT( ToString(qp), StrEq("test:\n" "minimize 1 * (5 + -1 x + -1 y + 1/2 * ( + 4 x^2 + 1 y^2))\n" "total: + 1 x + 1 y <= 1\n" "Bounds\n" "1 <= x <= 2\n" "-2 <= y <= 4\n")); } TEST(QuadraticProgramToStringTest, InvalidLpVectorSizes) { QuadraticProgram qp = TestLp(); qp.variable_lower_bounds.resize(3); EXPECT_THAT(ToString(qp), StartsWith("Quadratic program with inconsistent dimensions: ")); } // A matcher for Eigen Triplets. MATCHER_P3(IsEigenTriplet, row, col, value, std::string(negation ? "isn't" : "is") + " the triplet " + PrintToString(row) + "," + PrintToString(col) + "=" + PrintToString(value)) { return arg.row() == row && arg.col() == col && arg.value() == value; } TEST(CombineRepeatedTripletsInPlace, HandlesEmptyTriplets) { std::vector> triplets; CombineRepeatedTripletsInPlace(triplets); EXPECT_THAT(triplets, IsEmpty()); } TEST(CombineRepeatedTripletsInPlace, CorrectForSingleTriplet) { std::vector> triplets = {{1, 2, 3.0}}; CombineRepeatedTripletsInPlace(triplets); EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 3.0))); } TEST(CombineRepeatedTripletsInPlace, CorrectForDistinctTriplets) { std::vector> triplets = { {1, 2, 3.0}, {2, 1, 1.0}, {1, 1, 0.0}}; CombineRepeatedTripletsInPlace(triplets); EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 3.0), IsEigenTriplet(2, 1, 1.0), IsEigenTriplet(1, 1, 0.0))); } TEST(CombineRepeatedTripletsInPlace, CombinesDuplicatesAtStart) { std::vector> triplets = { {1, 2, 3.0}, {1, 2, -1.0}, {1, 1, 0.0}}; CombineRepeatedTripletsInPlace(triplets); EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 2.0), IsEigenTriplet(1, 1, 0.0))); } TEST(CombineRepeatedTripletsInPlace, CombinesDuplicatesAtEnd) { std::vector> triplets = { {1, 2, 3.0}, {2, 1, 1.0}, {2, 1, 1.0}}; CombineRepeatedTripletsInPlace(triplets); EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 3.0), IsEigenTriplet(2, 1, 2.0))); } TEST(CombineRepeatedTripletsInPlace, CombinesToSingleton) { std::vector> triplets = { {1, 2, 3.0}, {1, 2, 1.0}, {1, 2, 2.0}}; CombineRepeatedTripletsInPlace(triplets); EXPECT_THAT(triplets, ElementsAre(IsEigenTriplet(1, 2, 6.0))); } TEST(SetEigenMatrixFromTriplets, HandlesEmptyMatrix) { std::vector> triplets; Eigen::SparseMatrix matrix(2, 2); SetEigenMatrixFromTriplets(std::move(triplets), matrix); EXPECT_THAT(ToDense(matrix), EigenArrayEq({{0, 0}, // {0, 0}})); } TEST(SetEigenMatrixFromTriplets, CorrectForTinyMatrix) { std::vector> triplets = { {0, 0, 1.0}, {1, 0, -1.0}, {0, 0, 0.0}, {1, 1, 1.0}, {0, 0, 1.0}}; Eigen::SparseMatrix matrix(2, 2); SetEigenMatrixFromTriplets(std::move(triplets), matrix); EXPECT_THAT(ToDense(matrix), EigenArrayEq({{2, 0}, // {-1, 1}})); } } // namespace } // namespace operations_research::pdlp