// Copyright 2010-2021 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/math_opt/cpp/matchers.h" #include #include #include #include #include #include #include #include #include "absl/strings/str_cat.h" #include "absl/types/span.h" #include "gmock/gmock.h" #include "gtest/gtest.h" #include "ortools/base/logging.h" #include "ortools/math_opt/cpp/math_opt.h" #include "ortools/math_opt/cpp/variable_and_expressions.h" namespace operations_research { namespace math_opt { namespace { using ::testing::AllOf; using ::testing::AllOfArray; using ::testing::AnyOf; using ::testing::AnyOfArray; using ::testing::Contains; using ::testing::DoubleNear; using ::testing::Eq; using ::testing::ExplainMatchResult; using ::testing::Field; using ::testing::IsEmpty; using ::testing::Matcher; using ::testing::MatcherInterface; using ::testing::MatchResultListener; using ::testing::Optional; using ::testing::PrintToString; using ::testing::Property; } // namespace //////////////////////////////////////////////////////////////////////////////// // Printing //////////////////////////////////////////////////////////////////////////////// namespace { template struct Printer { explicit Printer(const T& t) : value(t) {} const T& value; friend std::ostream& operator<<(std::ostream& os, const Printer& printer) { os << PrintToString(printer.value); return os; } }; template Printer Print(const T& t) { return Printer(t); } } // namespace void PrintTo(const Termination& termination, std::ostream* os) { *os << "{reason: " << termination.reason; if (termination.limit.has_value()) { *os << ", limit: " << *termination.limit; } *os << ", detail: " << Print(termination.detail) << "}"; } void PrintTo(const PrimalSolution& primal_solution, std::ostream* const os) { *os << "{variable_values: " << Print(primal_solution.variable_values) << ", objective_value: " << Print(primal_solution.objective_value) << ", feasibility_status: " << Print(primal_solution.feasibility_status) << "}"; } void PrintTo(const DualSolution& dual_solution, std::ostream* const os) { *os << "{dual_values: " << Print(dual_solution.dual_values) << ", reduced_costs: " << Print(dual_solution.reduced_costs) << ", objective_value: " << Print(dual_solution.objective_value) << ", feasibility_status: " << Print(dual_solution.feasibility_status) << "}"; } void PrintTo(const PrimalRay& primal_ray, std::ostream* const os) { *os << "{variable_values: " << Print(primal_ray.variable_values) << "}"; } void PrintTo(const DualRay& dual_ray, std::ostream* const os) { *os << "{dual_values: " << Print(dual_ray.dual_values) << ", reduced_costs: " << Print(dual_ray.reduced_costs) << "}"; } void PrintTo(const Basis& basis, std::ostream* const os) { *os << "{variable_status: " << Print(basis.variable_status) << ", constraint_status: " << Print(basis.constraint_status) << ", basic_dual_feasibility: " << Print(basis.basic_dual_feasibility) << "}"; } void PrintTo(const Solution& solution, std::ostream* const os) { *os << "{primal_solution: " << Print(solution.primal_solution) << ", dual_solution: " << Print(solution.dual_solution) << ", basis: " << Print(solution.basis) << "}"; } void PrintTo(const SolveResult& result, std::ostream* const os) { *os << "{termination: " << Print(result.termination) << ", solve_stats: " << Print(result.solve_stats) << ", solutions: " << Print(result.solutions) << ", primal_rays: " << Print(result.primal_rays) << ", dual_rays: " << Print(result.dual_rays) << "}"; } //////////////////////////////////////////////////////////////////////////////// // IdMap Matchers //////////////////////////////////////////////////////////////////////////////// namespace { template class IdMapMatcher : public MatcherInterface> { public: IdMapMatcher(IdMap expected, const bool all_keys, const double tolerance) : expected_(std::move(expected)), all_keys_(all_keys), tolerance_(tolerance) { for (const auto [k, v] : expected_) { CHECK(!std::isnan(v)) << "Illegal NaN for key: " << k; } } bool MatchAndExplain(IdMap actual, MatchResultListener* const os) const override { for (const auto& [key, value] : expected_) { if (!actual.contains(key)) { *os << "expected key " << key << " not found"; return false; } if (!(std::abs(value - actual.at(key)) <= tolerance_)) { *os << "value for key " << key << " not within tolerance, expected: " << value << " but found: " << actual.at(key); return false; } } // Post condition: expected_ is a subset of actual. if (all_keys_ && expected_.size() != actual.size()) { for (const auto& [key, value] : actual) { if (!expected_.contains(key)) { *os << "found unexpected key " << key << " in actual"; return false; } } // expected_ subset of actual && expected_.size() != actual.size() implies // that there is a member A of actual not in expected. When the loop above // hits A, it will return, thus this line is unreachable. LOG(FATAL) << "unreachable"; } return true; } void DescribeTo(std::ostream* const os) const override { if (all_keys_) { *os << "has identical keys to "; } else { *os << "keys are contained in "; } PrintTo(expected_, os); *os << " and values within " << tolerance_; } void DescribeNegationTo(std::ostream* const os) const override { if (all_keys_) { *os << "either keys differ from "; } else { *os << "either has a key not in "; } PrintTo(expected_, os); *os << " or a value differs by more than " << tolerance_; } private: const IdMap expected_; const bool all_keys_; const double tolerance_; }; } // namespace Matcher> IsNearlySubsetOf(VariableMap expected, double tolerance) { return Matcher>(new IdMapMatcher( std::move(expected), /*all_keys=*/false, tolerance)); } Matcher> IsNear(VariableMap expected, const double tolerance) { return Matcher>(new IdMapMatcher( std::move(expected), /*all_keys=*/true, tolerance)); } Matcher> IsNearlySubsetOf( LinearConstraintMap expected, double tolerance) { return Matcher>( new IdMapMatcher(std::move(expected), /*all_keys=*/false, tolerance)); } Matcher> IsNear( LinearConstraintMap expected, const double tolerance) { return Matcher>( new IdMapMatcher(std::move(expected), /*all_keys=*/true, tolerance)); } template Matcher> IsNear(IdMap expected, const double tolerance) { return Matcher>( new IdMapMatcher(std::move(expected), /*all_keys=*/true, tolerance)); } template Matcher> IsNearlySubsetOf(IdMap expected, const double tolerance) { return Matcher>( new IdMapMatcher(std::move(expected), /*all_keys=*/false, tolerance)); } //////////////////////////////////////////////////////////////////////////////// // Matchers for LinearExpression and QuadraticExpression //////////////////////////////////////////////////////////////////////////////// testing::Matcher IsIdentical(LinearExpression expected) { CHECK(!std::isnan(expected.offset())) << "Illegal NaN-valued offset"; return AllOf( Property("storage", &LinearExpression::storage, Eq(expected.storage())), Property("offset", &LinearExpression::offset, testing::Eq(expected.offset())), Property("terms", &LinearExpression::terms, IsNear(expected.terms(), /*tolerance=*/0))); } testing::Matcher IsIdentical( QuadraticExpression expected) { CHECK(!std::isnan(expected.offset())) << "Illegal NaN-valued offset"; return AllOf( Property("storage", &QuadraticExpression::storage, Eq(expected.storage())), Property("offset", &QuadraticExpression::offset, testing::Eq(expected.offset())), Property("linear_terms", &QuadraticExpression::linear_terms, IsNear(expected.linear_terms(), /*tolerance=*/0)), Property("quadratic_terms", &QuadraticExpression::quadratic_terms, IsNear(expected.quadratic_terms(), /*tolerance=*/0))); } //////////////////////////////////////////////////////////////////////////////// // Matcher helpers //////////////////////////////////////////////////////////////////////////////// namespace { template class RayMatcher : public MatcherInterface { public: RayMatcher(RayType expected, const double tolerance) : expected_(std::move(expected)), tolerance_(tolerance) {} void DescribeTo(std::ostream* os) const final { *os << "after L_inf normalization, is within tolerance: " << tolerance_ << " of expected: "; PrintTo(expected_, os); } void DescribeNegationTo(std::ostream* const os) const final { *os << "after L_inf normalization, is not within tolerance: " << tolerance_ << " of expected: "; PrintTo(expected_, os); } protected: const RayType expected_; const double tolerance_; }; // Alias to use the std::optional templated adaptor. Matcher IsNear(double expected, const double tolerance) { return DoubleNear(expected, tolerance); } template Matcher> IsNear(std::optional expected, const double tolerance) { if (expected.has_value()) { return Optional(IsNear(*expected, tolerance)); } return testing::Eq(std::nullopt); } // Custom std::optional for basis. Matcher> BasisIs(const std::optional& expected) { if (expected.has_value()) { return Optional(BasisIs(*expected)); } return testing::Eq(std::nullopt); } testing::Matcher> IsNear( const std::vector& expected_solutions, const SolutionMatcherOptions options) { if (expected_solutions.empty()) { return IsEmpty(); } std::vector> matchers; for (const Solution& sol : expected_solutions) { matchers.push_back(IsNear(sol, options)); } return ::testing::ElementsAreArray(matchers); } } // namespace //////////////////////////////////////////////////////////////////////////////// // Matchers for Solutions //////////////////////////////////////////////////////////////////////////////// Matcher IsNear(PrimalSolution expected, const double tolerance) { return AllOf(Field("variable_values", &PrimalSolution::variable_values, IsNear(expected.variable_values, tolerance)), Field("objective_value", &PrimalSolution::objective_value, IsNear(expected.objective_value, tolerance)), Field("feasibility_status", &PrimalSolution::feasibility_status, expected.feasibility_status)); } Matcher IsNear(DualSolution expected, const double tolerance) { return AllOf(Field("dual_values", &DualSolution::dual_values, IsNear(expected.dual_values, tolerance)), Field("reduced_costs", &DualSolution::reduced_costs, IsNear(expected.reduced_costs, tolerance)), Field("objective_value", &DualSolution::objective_value, IsNear(expected.objective_value, tolerance)), Field("feasibility_status", &DualSolution::feasibility_status, expected.feasibility_status)); } Matcher BasisIs(const Basis& expected) { return AllOf(Field("variable_status", &Basis::variable_status, expected.variable_status), Field("constraint_status", &Basis::constraint_status, expected.constraint_status), Field("basic_dual_feasibility", &Basis::basic_dual_feasibility, expected.basic_dual_feasibility)); } Matcher IsNear(Solution expected, const SolutionMatcherOptions options) { std::vector> to_check; if (options.check_primal) { to_check.push_back( Field("primal_solution", &Solution::primal_solution, IsNear(expected.primal_solution, options.tolerance))); } if (options.check_dual) { to_check.push_back( Field("dual_solution", &Solution::dual_solution, IsNear(expected.dual_solution, options.tolerance))); } if (options.check_basis) { to_check.push_back( Field("basis", &Solution::basis, BasisIs(expected.basis))); } return AllOfArray(to_check); } //////////////////////////////////////////////////////////////////////////////// // Primal Ray Matcher //////////////////////////////////////////////////////////////////////////////// namespace { template double InfinityNorm(const IdMap& vector) { double infinity_norm = 0.0; for (auto [id, value] : vector) { infinity_norm = std::max(infinity_norm, std::abs(value)); } return infinity_norm; } // Returns a normalized primal ray. // // The normalization is done using infinity norm: // // ray / ||ray||_inf // // If the input ray norm is zero, the ray is returned unchanged. PrimalRay NormalizePrimalRay(PrimalRay ray) { const double norm = InfinityNorm(ray.variable_values); if (norm != 0.0) { for (auto entry : ray.variable_values) { entry.second /= norm; } } return ray; } class PrimalRayMatcher : public RayMatcher { public: PrimalRayMatcher(PrimalRay expected, const double tolerance) : RayMatcher(std::move(expected), tolerance) {} bool MatchAndExplain(PrimalRay actual, MatchResultListener* const os) const override { auto normalized_actual = NormalizePrimalRay(actual); auto normalized_expected = NormalizePrimalRay(expected_); if (os->IsInterested()) { *os << "actual normalized: " << PrintToString(normalized_actual) << ", expected normalized: " << PrintToString(normalized_expected); } return ExplainMatchResult( IsNear(normalized_expected.variable_values, tolerance_), normalized_actual.variable_values, os); } }; } // namespace Matcher IsNear(PrimalRay expected, const double tolerance) { return Matcher( new PrimalRayMatcher(std::move(expected), tolerance)); } Matcher PrimalRayIsNear(VariableMap expected_var_values, const double tolerance) { PrimalRay expected; expected.variable_values = std::move(expected_var_values); return IsNear(expected, tolerance); } //////////////////////////////////////////////////////////////////////////////// // Dual Ray Matcher //////////////////////////////////////////////////////////////////////////////// namespace { // Returns a normalized dual ray. // // The normalization is done using infinity norm: // // ray / ||ray||_inf // // If the input ray norm is zero, the ray is returned unchanged. DualRay NormalizeDualRay(DualRay ray) { const double norm = std::max(InfinityNorm(ray.dual_values), InfinityNorm(ray.reduced_costs)); if (norm != 0.0) { for (auto entry : ray.dual_values) { entry.second /= norm; } for (auto entry : ray.reduced_costs) { entry.second /= norm; } } return ray; } class DualRayMatcher : public RayMatcher { public: DualRayMatcher(DualRay expected, const double tolerance) : RayMatcher(std::move(expected), tolerance) {} bool MatchAndExplain(DualRay actual, MatchResultListener* os) const override { auto normalized_actual = NormalizeDualRay(actual); auto normalized_expected = NormalizeDualRay(expected_); if (os->IsInterested()) { *os << "actual normalized: " << PrintToString(normalized_actual) << ", expected normalized: " << PrintToString(normalized_expected); } return ExplainMatchResult( IsNear(normalized_expected.dual_values, tolerance_), normalized_actual.dual_values, os) && ExplainMatchResult( IsNear(normalized_expected.reduced_costs, tolerance_), normalized_actual.reduced_costs, os); } }; } // namespace Matcher IsNear(DualRay expected, const double tolerance) { return Matcher(new DualRayMatcher(std::move(expected), tolerance)); } //////////////////////////////////////////////////////////////////////////////// // SolveResult termination reason matchers //////////////////////////////////////////////////////////////////////////////// Matcher TerminatesWithOneOf( const std::vector& allowed) { return Field("termination", &SolveResult::termination, Field("reason", &Termination::reason, AnyOfArray(allowed))); } Matcher TerminatesWith(const TerminationReason expected) { return Field("termination", &SolveResult::termination, Field("reason", &Termination::reason, expected)); } namespace { testing::Matcher LimitIs(const Limit expected, const bool allow_limit_undetermined) { if (allow_limit_undetermined) { return Field("termination", &SolveResult::termination, Field("limit", &Termination::limit, AnyOf(Limit::kUndetermined, expected))); } return Field("termination", &SolveResult::termination, Field("limit", &Termination::limit, expected)); } } // namespace testing::Matcher TerminatesWithLimit( const Limit expected, const bool allow_limit_undetermined) { std::vector> matchers; matchers.push_back(LimitIs(expected, allow_limit_undetermined)); matchers.push_back(TerminatesWithOneOf( {TerminationReason::kFeasible, TerminationReason::kNoSolutionFound})); return ::testing::AllOfArray(matchers); } testing::Matcher TerminatesWithReasonFeasible( const Limit expected, const bool allow_limit_undetermined) { std::vector> matchers; matchers.push_back(LimitIs(expected, allow_limit_undetermined)); matchers.push_back(TerminatesWith(TerminationReason::kFeasible)); return ::testing::AllOfArray(matchers); } testing::Matcher TerminatesWithReasonNoSolutionFound( const Limit expected, const bool allow_limit_undetermined) { std::vector> matchers; matchers.push_back(LimitIs(expected, allow_limit_undetermined)); matchers.push_back(TerminatesWith(TerminationReason::kNoSolutionFound)); return ::testing::AllOfArray(matchers); } template std::string MatcherToStringImpl(const MatcherType& matcher, const bool negate) { std::ostringstream os; if (negate) { matcher.DescribeNegationTo(&os); } else { matcher.DescribeTo(&os); } return os.str(); } template std::string MatcherToString(const Matcher& matcher, bool negate) { return MatcherToStringImpl(matcher, negate); } // Polymorphic matchers do not always define DescribeTo, see // The type may not be a matcher, but it will implement DescribeTo. template std::string MatcherToString(const ::testing::PolymorphicMatcher& matcher, bool negate) { return MatcherToStringImpl(matcher.impl(), negate); } MATCHER_P(FirstElementIs, first_element_matcher, (negation ? absl::StrCat("is empty or first element ", MatcherToString(first_element_matcher, true)) : absl::StrCat("has at least one element and first element ", MatcherToString(first_element_matcher, false)))) { return ExplainMatchResult(UnorderedElementsAre(first_element_matcher), absl::MakeSpan(arg).subspan(0, 1), result_listener); } Matcher IsOptimal(const std::optional expected_objective, const double tolerance) { std::vector> matchers; matchers.push_back(Field( "termination", &SolveResult::termination, Field("reason", &Termination::reason, TerminationReason::kOptimal))); if (expected_objective.has_value()) { matchers.push_back(Field( "solutions", &SolveResult::solutions, FirstElementIs(Field( "primal_solution", &Solution::primal_solution, Optional(Field("objective_value", &PrimalSolution::objective_value, IsNear(*expected_objective, tolerance))))))); } return ::testing::AllOfArray(matchers); } Matcher IsOptimalWithSolution( const double expected_objective, const VariableMap expected_variable_values, const double tolerance) { return AllOf( IsOptimal(std::make_optional(expected_objective), tolerance), HasSolution( PrimalSolution{.variable_values = expected_variable_values, .objective_value = expected_objective, .feasibility_status = SolutionStatus::kFeasible}, tolerance)); } Matcher IsOptimalWithDualSolution( const double expected_objective, const LinearConstraintMap expected_dual_values, const VariableMap expected_reduced_costs, const double tolerance) { return AllOf( IsOptimal(std::make_optional(expected_objective), tolerance), HasDualSolution( DualSolution{ .dual_values = expected_dual_values, .reduced_costs = expected_reduced_costs, .objective_value = std::make_optional(expected_objective), .feasibility_status = SolutionStatus::kFeasible}, tolerance)); } Matcher HasSolution(PrimalSolution expected, const double tolerance) { return ::testing::Field( "solutions", &SolveResult::solutions, Contains(Field("primal_solution", &Solution::primal_solution, Optional(IsNear(std::move(expected), tolerance))))); } Matcher HasDualSolution(DualSolution expected, const double tolerance) { return ::testing::Field( "solutions", &SolveResult::solutions, Contains(Field("dual_solution", &Solution::dual_solution, Optional(IsNear(std::move(expected), tolerance))))); } Matcher HasPrimalRay(PrimalRay expected, const double tolerance) { return ::testing::Field("primal_rays", &SolveResult::primal_rays, Contains(IsNear(std::move(expected), tolerance))); } Matcher HasPrimalRay(VariableMap expected_vars, const double tolerance) { PrimalRay ray; ray.variable_values = std::move(expected_vars); return HasPrimalRay(std::move(ray), tolerance); } Matcher HasDualRay(DualRay expected, const double tolerance) { return ::testing::Field("dual_rays", &SolveResult::dual_rays, Contains(IsNear(std::move(expected), tolerance))); } namespace { bool MightTerminateWithRays(const TerminationReason reason) { switch (reason) { case TerminationReason::kInfeasibleOrUnbounded: case TerminationReason::kUnbounded: case TerminationReason::kInfeasible: return true; default: return false; } } std::vector CompatibleReasons( const TerminationReason expected, const bool inf_or_unb_soft_match) { if (!inf_or_unb_soft_match) { return {expected}; } switch (expected) { case TerminationReason::kUnbounded: return {TerminationReason::kUnbounded, TerminationReason::kInfeasibleOrUnbounded}; case TerminationReason::kInfeasible: return {TerminationReason::kInfeasible, TerminationReason::kInfeasibleOrUnbounded}; case TerminationReason::kInfeasibleOrUnbounded: return {TerminationReason::kUnbounded, TerminationReason::kInfeasible, TerminationReason::kInfeasibleOrUnbounded}; default: return {expected}; } } Matcher> CheckSolutions( const std::vector& expected_solutions, const SolveResultMatcherOptions& options) { if (options.first_solution_only && !expected_solutions.empty()) { return FirstElementIs( IsNear(expected_solutions[0], SolutionMatcherOptions{.tolerance = options.tolerance, .check_primal = true, .check_dual = options.check_dual, .check_basis = options.check_basis})); } return IsNear(expected_solutions, SolutionMatcherOptions{.tolerance = options.tolerance, .check_primal = true, .check_dual = options.check_dual, .check_basis = options.check_basis}); } template Matcher> AnyRayNear( const std::vector& expected_rays, const double tolerance) { std::vector> matchers; for (const RayType& ray : expected_rays) { matchers.push_back(IsNear(ray, tolerance)); } return ::testing::Contains(::testing::AnyOfArray(matchers)); } template Matcher> AllRaysNear( const std::vector& expected_rays, const double tolerance) { std::vector> matchers; for (const RayType& ray : expected_rays) { matchers.push_back(IsNear(ray, tolerance)); } return ::testing::UnorderedElementsAreArray(matchers); } template Matcher> CheckRays( const std::vector& expected_rays, const double tolerance, bool check_all) { if (expected_rays.empty()) { return ::testing::IsEmpty(); } if (check_all) { return AllRaysNear(expected_rays, tolerance); } return AnyRayNear(expected_rays, tolerance); } } // namespace Matcher IsConsistentWith( const SolveResult& expected, const SolveResultMatcherOptions& options) { std::vector> to_check; to_check.push_back(TerminatesWithOneOf(CompatibleReasons( expected.termination.reason, options.inf_or_unb_soft_match))); const bool skip_solution = MightTerminateWithRays(expected.termination.reason) && !options.check_solutions_if_inf_or_unbounded; if (!skip_solution) { to_check.push_back(Field("solutions", &SolveResult::solutions, CheckSolutions(expected.solutions, options))); } if (options.check_rays) { to_check.push_back(Field("primal_rays", &SolveResult::primal_rays, CheckRays(expected.primal_rays, options.tolerance, !options.first_solution_only))); to_check.push_back(Field("dual_rays", &SolveResult::dual_rays, CheckRays(expected.dual_rays, options.tolerance, !options.first_solution_only))); } return AllOfArray(to_check); } //////////////////////////////////////////////////////////////////////////////// // Rarely used //////////////////////////////////////////////////////////////////////////////// Matcher DidUpdate() { return ::testing::Field("did_update", &IncrementalSolver::UpdateResult::did_update, ::testing::IsTrue()); } } // namespace math_opt } // namespace operations_research