diff --git a/cpp/cuopt_cli.cpp b/cpp/cuopt_cli.cpp index cbfc0b6b9f..5a325f2a0f 100644 --- a/cpp/cuopt_cli.cpp +++ b/cpp/cuopt_cli.cpp @@ -38,17 +38,18 @@ static char cuda_module_loading_env[] = "CUDA_MODULE_LOADING=EAGER"; * @brief Command line interface for solving Linear Programming (LP) and Mixed Integer Programming * (MIP) problems using cuOpt * - * This CLI provides a simple interface to solve LP/MIP problems using cuOpt. It accepts MPS format - * input files and various solver parameters. + * This CLI provides a simple interface to solve LP/MIP problems using cuOpt. It accepts MPS, QPS, + * or LP format input files (dispatched automatically by extension; see run_single_file below for + * the full list of supported suffixes) and various solver parameters. * * Usage: * ``` - * cuopt_cli [OPTIONS] - * cuopt_cli [OPTIONS] + * cuopt_cli [OPTIONS] + * cuopt_cli [OPTIONS] * ``` * * Required arguments: - * - : Path to the MPS format input file containing the optimization problem + * - : Path to the MPS or LP format input file containing the optimization problem * * Optional arguments: * - --initial-solution: Path to initial solution file in SOL format @@ -84,7 +85,10 @@ inline cuopt::init_logger_t dummy_logger( /** * @brief Run a single file - * @param file_path Path to the MPS format input file containing the optimization problem + * @param file_path Path to the input file. Dispatched by extension: + * .lp/.lp.gz/.lp.bz2 → LP parser; + * .mps/.qps and their .gz/.bz2 variants → MPS parser; + * anything else is rejected. * @param initial_solution_file Path to initial solution file in SOL format * @param settings Merged solver settings (config file loaded in main, then CLI overrides applied) */ @@ -98,23 +102,21 @@ int run_single_file(const std::string& file_path, std::string base_filename = file_path.substr(file_path.find_last_of("/\\") + 1); - constexpr bool input_mps_strict = false; cuopt::linear_programming::io::mps_data_model_t mps_data_model; bool parsing_failed = false; auto timer = cuopt::timer_t(settings.get_parameter(CUOPT_TIME_LIMIT)); { CUOPT_LOG_INFO("Reading file %s", base_filename.c_str()); try { - mps_data_model = - cuopt::linear_programming::io::parse_mps(file_path, input_mps_strict); + mps_data_model = cuopt::linear_programming::io::parse_problem(file_path); } catch (const std::logic_error& e) { - CUOPT_LOG_ERROR("MPS parser execption: %s", e.what()); + CUOPT_LOG_ERROR("Parser exception: %s", e.what()); parsing_failed = true; } } if (parsing_failed) { auto log = dummy_logger(settings); - CUOPT_LOG_ERROR("Parsing MPS failed. Exiting!"); + CUOPT_LOG_ERROR("Parsing input file failed. Exiting!"); return -1; } CUOPT_LOG_INFO("Read file %s in %.2f seconds", base_filename.c_str(), timer.elapsed_time()); @@ -279,7 +281,10 @@ int main(int argc, char* argv[]) argparse::ArgumentParser program("cuopt_cli", version_string); // Define all arguments with appropriate defaults and help messages - program.add_argument("filename").help("input mps file").nargs(1).required(); + program.add_argument("filename") + .help("input MPS or LP file (dispatched by .lp / .mps extension)") + .nargs(1) + .required(); // FIXME: use a standard format for initial solution file program.add_argument("--initial-solution") diff --git a/cpp/include/cuopt/linear_programming/cuopt_c.h b/cpp/include/cuopt/linear_programming/cuopt_c.h index 4c4d44c764..6665ba0fac 100644 --- a/cpp/include/cuopt/linear_programming/cuopt_c.h +++ b/cpp/include/cuopt/linear_programming/cuopt_c.h @@ -100,12 +100,18 @@ cuopt_int_t cuOptGetVersion(cuopt_int_t* version_major, cuopt_int_t* version_patch); /** - * @brief Read an optimization problem from an MPS file. + * @brief Read an optimization problem from an MPS, QPS, or LP file. * - * @param[in] filename - The path to the MPS file. + * The file format is dispatched on the filename extension + * (case-insensitive): + * - ".lp", ".lp.gz", ".lp.bz2" → LP parser + * - ".mps", ".mps.gz", ".mps.bz2", ".qps", ".qps.gz", ".qps.bz2" → MPS parser + * - anything else (including no extension) is rejected. + * + * @param[in] filename - The path to the MPS, QPS, or LP file. * * @param[out] problem_ptr - A pointer to a cuOptOptimizationProblem. On output - * the problem will be created and initialized with the data from the MPS file + * the problem will be created and initialized with the data from the input file. * * @return A status code indicating success or failure. */ diff --git a/cpp/include/cuopt/linear_programming/io/parser.hpp b/cpp/include/cuopt/linear_programming/io/parser.hpp index ef55dabf52..434efa5f97 100644 --- a/cpp/include/cuopt/linear_programming/io/parser.hpp +++ b/cpp/include/cuopt/linear_programming/io/parser.hpp @@ -9,6 +9,9 @@ #include +#include +#include +#include #include #include @@ -55,4 +58,89 @@ template mps_data_model_t parse_mps_from_string(std::string_view mps_contents, bool fixed_mps_format = false); +/** + * @brief Reads a linear, mixed-integer, or quadratic optimization problem from + * a file in LP format. + * + * The LP format is a human-readable alternative to MPS format. This parser + * supports the conventional LP dialect implemented by most commercial + * optimization solvers (not the lpsolve variant, which has a different + * syntax). + * + * Scope: LP, MIP, and QP problems are supported, plus semi-continuous + * variables (via a Semi-Continuous section; finite upper bound required) + * and quadratic constraints (QCQP; `<=` only). + * + * Quadratic terms appear inside `[ ... ]` blocks. The convention differs + * between objective and constraints: + * - Objective bracket: MUST be followed by `/ 2` (the LP file states + * coefficients in the `0.5 x^T Q x` convention). + * - Constraint bracket: MUST NOT be followed by `/ 2`; coefficients are + * taken at face value (`x^T Q x`). + * + * SOS constraints, PWL objectives, general constraints, and user cuts cause + * a ValidationError when encountered. + * + * Compressed inputs (.lp.gz, .lp.bz2) are supported when zlib / libbzip2 + * are installed (same dispatching as parse_mps). + * + * @param[in] lp_file_path Path to the LP file. + * @return mps_data_model_t A fully formed LP/MIP/QP problem representing the + * given file. + */ +template +mps_data_model_t parse_lp(const std::string& lp_file_path); + +/** + * @brief Reads an LP, MIP, or QP problem from in-memory file contents. + * + * This parses the same plain-text LP format as parse_lp(), but the input is + * already loaded in memory. Compressed .lp.gz/.lp.bz2 inputs are only + * supported by parse_lp() because compression is detected from the file + * path. Supports the same scope as parse_lp() (LP, MIP, QP, plus + * semi-continuous variables). + * + * @param[in] lp_contents LP file contents. + * @return mps_data_model_t A fully formed LP/MIP/QP problem representing the + * given content. + */ +template +mps_data_model_t parse_lp_from_string(std::string_view lp_contents); + +/** + * @brief Reads an optimization problem from a file, dispatching on the file + * extension. Extension matching is case-insensitive. + * + * Routing: + * - .mps, .mps.gz, .mps.bz2, .qps, .qps.gz, .qps.bz2 → parse_mps() + * - .lp, .lp.gz, .lp.bz2 → parse_lp() + * - anything else → std::logic_error + * + * This is the entry point of choice for user-facing tools (CLI, C API) that + * want both formats to "just work" without an explicit format flag. + * + * @param[in] path Path to the input file. + * @return mps_data_model_t The parsed problem. + */ +template +inline mps_data_model_t parse_problem(const std::string& path) +{ + std::string lower(path); + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (lower.ends_with(".mps") || lower.ends_with(".mps.gz") || lower.ends_with(".mps.bz2") || + lower.ends_with(".qps") || lower.ends_with(".qps.gz") || lower.ends_with(".qps.bz2")) { + return parse_mps(path); + } + if (lower.ends_with(".lp") || lower.ends_with(".lp.gz") || lower.ends_with(".lp.bz2")) { + return parse_lp(path); + } + throw std::logic_error( + "parse_problem: unrecognized input file extension. Supported (case-insensitive): " + ".mps, .mps.gz, .mps.bz2, .qps, .qps.gz, .qps.bz2, .lp, .lp.gz, .lp.bz2. " + "Given path: " + + path); +} + } // namespace cuopt::linear_programming::io diff --git a/cpp/include/cuopt/linear_programming/io/utilities/cython_mps_parser.hpp b/cpp/include/cuopt/linear_programming/io/utilities/cython_parser.hpp similarity index 80% rename from cpp/include/cuopt/linear_programming/io/utilities/cython_mps_parser.hpp rename to cpp/include/cuopt/linear_programming/io/utilities/cython_parser.hpp index d787eb2dcf..eb4044d1d0 100644 --- a/cpp/include/cuopt/linear_programming/io/utilities/cython_mps_parser.hpp +++ b/cpp/include/cuopt/linear_programming/io/utilities/cython_parser.hpp @@ -17,5 +17,8 @@ namespace cython { std::unique_ptr> call_parse_mps( const std::string& mps_file_path, bool fixed_mps_format); +std::unique_ptr> call_parse_lp( + const std::string& lp_file_path); + } // namespace cython } // namespace cuopt diff --git a/cpp/src/io/CMakeLists.txt b/cpp/src/io/CMakeLists.txt index d91350a222..cc4affa890 100644 --- a/cpp/src/io/CMakeLists.txt +++ b/cpp/src/io/CMakeLists.txt @@ -5,12 +5,14 @@ set(PARSERS_SRC_FILES ${CMAKE_CURRENT_SOURCE_DIR}/data_model_view.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/file_to_string.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/lp_parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mps_data_model.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mps_parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mps_writer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/writer.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/utilities/cython_mps_parser.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/utilities/cython_parser.cpp ) set(CUOPT_SRC_FILES ${CUOPT_SRC_FILES} ${PARSERS_SRC_FILES} PARENT_SCOPE) diff --git a/cpp/src/io/file_to_string.cpp b/cpp/src/io/file_to_string.cpp new file mode 100644 index 0000000000..f910eb977e --- /dev/null +++ b/cpp/src/io/file_to_string.cpp @@ -0,0 +1,246 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include + +#include + +#include +#include +#include +#include + +#ifdef MPS_PARSER_WITH_BZIP2 +#include +#endif // MPS_PARSER_WITH_BZIP2 + +#ifdef MPS_PARSER_WITH_ZLIB +#include +#endif // MPS_PARSER_WITH_ZLIB + +#if defined(MPS_PARSER_WITH_BZIP2) || defined(MPS_PARSER_WITH_ZLIB) +#include +#endif // MPS_PARSER_WITH_BZIP2 || MPS_PARSER_WITH_ZLIB + +namespace { +using cuopt::linear_programming::io::error_type_t; +using cuopt::linear_programming::io::mps_parser_expects; +using cuopt::linear_programming::io::mps_parser_expects_fatal; + +struct FcloseDeleter { + void operator()(FILE* fp) + { + mps_parser_expects_fatal( + fclose(fp) == 0, error_type_t::ValidationError, "Error closing input file!"); + } +}; +} // end namespace + +#ifdef MPS_PARSER_WITH_BZIP2 +namespace { +using BZ2_bzReadOpen_t = decltype(&BZ2_bzReadOpen); +using BZ2_bzReadClose_t = decltype(&BZ2_bzReadClose); +using BZ2_bzRead_t = decltype(&BZ2_bzRead); + +std::vector bz2_file_to_string(const std::string& file) +{ + struct DlCloseDeleter { + void operator()(void* fp) + { + mps_parser_expects_fatal( + dlclose(fp) == 0, error_type_t::ValidationError, "Error closing libbz2.so!"); + } + }; + struct BzReadCloseDeleter { + void operator()(void* f) + { + int bzerror; + if (f != nullptr) fptr(&bzerror, f); + mps_parser_expects_fatal( + bzerror == BZ_OK, error_type_t::ValidationError, "Error closing bzip2 file!"); + } + BZ2_bzReadClose_t fptr = nullptr; + }; + + std::unique_ptr lbz2handle{dlopen("libbz2.so", RTLD_LAZY)}; + mps_parser_expects( + lbz2handle != nullptr, + error_type_t::ValidationError, + "Could not open .bz2 file since libbz2.so was not found. In order to open .bz2 files " + "directly, please ensure libbzip2 is installed. Alternatively, decompress the .bz2 file " + "manually and open the uncompressed file. Given path: %s", + file.c_str()); + + BZ2_bzReadOpen_t BZ2_bzReadOpen = + reinterpret_cast(dlsym(lbz2handle.get(), "BZ2_bzReadOpen")); + BZ2_bzReadClose_t BZ2_bzReadClose = + reinterpret_cast(dlsym(lbz2handle.get(), "BZ2_bzReadClose")); + BZ2_bzRead_t BZ2_bzRead = reinterpret_cast(dlsym(lbz2handle.get(), "BZ2_bzRead")); + mps_parser_expects( + BZ2_bzReadOpen != nullptr && BZ2_bzReadClose != nullptr && BZ2_bzRead != nullptr, + error_type_t::ValidationError, + "Error loading libbzip2! Library version might be incompatible. Please decompress the .bz2 " + "file manually and open the uncompressed file. Given path: %s", + file.c_str()); + + std::unique_ptr fp{fopen(file.c_str(), "rb")}; + mps_parser_expects(fp != nullptr, + error_type_t::ValidationError, + "Error opening input file! Given path: %s", + file.c_str()); + int bzerror = BZ_OK; + std::unique_ptr bzfile{ + BZ2_bzReadOpen(&bzerror, fp.get(), 0, 0, nullptr, 0), {BZ2_bzReadClose}}; + mps_parser_expects(bzerror == BZ_OK, + error_type_t::ValidationError, + "Could not open bzip2 compressed file! Given path: %s", + file.c_str()); + + std::vector buf; + const size_t readbufsize = 1ull << 24; // 16MiB - just a guess. + std::vector readbuf(readbufsize); + while (bzerror == BZ_OK) { + const size_t bytes_read = BZ2_bzRead(&bzerror, bzfile.get(), readbuf.data(), readbuf.size()); + if (bzerror == BZ_OK || bzerror == BZ_STREAM_END) { + buf.insert(buf.end(), begin(readbuf), begin(readbuf) + bytes_read); + } + } + buf.push_back('\0'); + mps_parser_expects(bzerror == BZ_STREAM_END, + error_type_t::ValidationError, + "Error in bzip2 decompression of input file! Given path: %s", + file.c_str()); + return buf; +} +} // end namespace +#endif // MPS_PARSER_WITH_BZIP2 + +#ifdef MPS_PARSER_WITH_ZLIB +namespace { +using gzopen_t = decltype(&gzopen); +using gzclose_r_t = decltype(&gzclose_r); +using gzbuffer_t = decltype(&gzbuffer); +using gzread_t = decltype(&gzread); +using gzerror_t = decltype(&gzerror); + +std::vector zlib_file_to_string(const std::string& file) +{ + struct DlCloseDeleter { + void operator()(void* fp) + { + mps_parser_expects_fatal( + dlclose(fp) == 0, error_type_t::ValidationError, "Error closing libz.so!"); + } + }; + struct GzCloseDeleter { + void operator()(gzFile_s* f) + { + int err = fptr(f); + mps_parser_expects_fatal( + err == Z_OK, error_type_t::ValidationError, "Error closing gz file!"); + } + gzclose_r_t fptr = nullptr; + }; + + std::unique_ptr lzhandle{dlopen("libz.so.1", RTLD_LAZY)}; + mps_parser_expects( + lzhandle != nullptr, + error_type_t::ValidationError, + "Could not open .gz file since libz.so was not found. In order to open .gz files " + "directly, please ensure zlib is installed. Alternatively, decompress the .gz file " + "manually and open the uncompressed file. Given path: %s", + file.c_str()); + gzopen_t gzopen = reinterpret_cast(dlsym(lzhandle.get(), "gzopen")); + gzclose_r_t gzclose_r = reinterpret_cast(dlsym(lzhandle.get(), "gzclose_r")); + gzbuffer_t gzbuffer = reinterpret_cast(dlsym(lzhandle.get(), "gzbuffer")); + gzread_t gzread = reinterpret_cast(dlsym(lzhandle.get(), "gzread")); + gzerror_t gzerror = reinterpret_cast(dlsym(lzhandle.get(), "gzerror")); + mps_parser_expects( + gzopen != nullptr && gzclose_r != nullptr && gzbuffer != nullptr && gzread != nullptr && + gzerror != nullptr, + error_type_t::ValidationError, + "Error loading zlib! Library version might be incompatible. Please decompress the .gz file " + "manually and open the uncompressed file. Given path: %s", + file.c_str()); + std::unique_ptr gzfp{gzopen(file.c_str(), "rb"), {gzclose_r}}; + mps_parser_expects(gzfp != nullptr, + error_type_t::ValidationError, + "Error opening compressed input file! Given path: %s", + file.c_str()); + int zlib_status = gzbuffer(gzfp.get(), 1 << 20); // 1 MiB + mps_parser_expects(zlib_status == Z_OK, + error_type_t::ValidationError, + "Could not set zlib internal buffer size for decompression! Given path: %s", + file.c_str()); + std::vector buf; + const size_t readbufsize = 1ull << 24; // 16MiB + std::vector readbuf(readbufsize); + int bytes_read = -1; + while (bytes_read != 0) { + bytes_read = gzread(gzfp.get(), readbuf.data(), readbuf.size()); + if (bytes_read > 0) { buf.insert(buf.end(), begin(readbuf), begin(readbuf) + bytes_read); } + if (bytes_read < 0) { + gzerror(gzfp.get(), &zlib_status); + break; + } + } + buf.push_back('\0'); + mps_parser_expects(zlib_status == Z_OK, + error_type_t::ValidationError, + "Error in zlib decompression of input file! Given path: %s", + file.c_str()); + return buf; +} +} // end namespace +#endif // MPS_PARSER_WITH_ZLIB + +namespace cuopt::linear_programming::io::detail { + +std::vector file_to_string(const std::string& file) +{ +#ifdef MPS_PARSER_WITH_BZIP2 + if (file.size() > 4 && file.substr(file.size() - 4, 4) == ".bz2") { + return bz2_file_to_string(file); + } +#endif // MPS_PARSER_WITH_BZIP2 + +#ifdef MPS_PARSER_WITH_ZLIB + if (file.size() > 3 && file.substr(file.size() - 3, 3) == ".gz") { + return zlib_file_to_string(file); + } +#endif // MPS_PARSER_WITH_ZLIB + + // Faster than using C++ I/O + std::unique_ptr fp{fopen(file.c_str(), "r")}; + mps_parser_expects(fp != nullptr, + error_type_t::ValidationError, + "Error opening input file! Given path: %s", + file.c_str()); + + mps_parser_expects(fseek(fp.get(), 0L, SEEK_END) == 0, + error_type_t::ValidationError, + "Error seeking input file! Given path: %s", + file.c_str()); + const long bufsize = ftell(fp.get()); + mps_parser_expects(bufsize != -1L, + error_type_t::ValidationError, + "Error sizing input file! Given path: %s", + file.c_str()); + std::vector buf(bufsize + 1); + rewind(fp.get()); + + mps_parser_expects( + fread(buf.data(), sizeof(char), bufsize, fp.get()) == static_cast(bufsize), + error_type_t::ValidationError, + "Error reading input file! Given path: %s", + file.c_str()); + buf[bufsize] = '\0'; + + return buf; +} + +} // namespace cuopt::linear_programming::io::detail diff --git a/cpp/src/io/file_to_string.hpp b/cpp/src/io/file_to_string.hpp new file mode 100644 index 0000000000..94b2df821d --- /dev/null +++ b/cpp/src/io/file_to_string.hpp @@ -0,0 +1,24 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include + +namespace cuopt::linear_programming::io::detail { + +// Reads `file` into a buffer and appends a trailing '\0'. +// +// The dispatcher looks at the extension: +// - ".bz2" → libbz2 (dlopen'd at runtime), if MPS_PARSER_WITH_BZIP2. +// - ".gz" → libz (dlopen'd at runtime), if MPS_PARSER_WITH_ZLIB. +// - otherwise → plain fopen. +// The returned buffer's size includes the null terminator. +std::vector file_to_string(const std::string& file); + +} // namespace cuopt::linear_programming::io::detail diff --git a/cpp/src/io/lp_parser.cpp b/cpp/src/io/lp_parser.cpp new file mode 100644 index 0000000000..7341c7fa9e --- /dev/null +++ b/cpp/src/io/lp_parser.cpp @@ -0,0 +1,1393 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::linear_programming::io { + +namespace { + +// =========================================================================== +// Small character / string helpers +// =========================================================================== + +// Per the LP-format convention, variable names may use letters and a specific +// set of punctuation characters. Characters used by the grammar (+, -, *, ^, +// :, =, <, >, [, ], \, whitespace) are excluded. Digits and '.' are valid +// mid-name but not as the starting character. +bool is_name_start_char(char c) +{ + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) return true; + switch (c) { + case '!': + case '"': + case '#': + case '$': + case '%': + case '&': + case '(': + case ')': + case ',': + case ';': + case '?': + case '@': + case '_': + case '`': + case '\'': + case '{': + case '}': + case '|': + case '~': return true; + default: return false; + } +} + +bool is_name_char(char c) +{ + if (is_name_start_char(c)) return true; + return (c >= '0' && c <= '9') || c == '.' || c == '/'; +} + +char to_lower(char c) +{ + if (c >= 'A' && c <= 'Z') return static_cast(c - 'A' + 'a'); + return c; +} + +std::string lowercase(std::string_view s) +{ + std::string out; + out.reserve(s.size()); + for (char c : s) + out.push_back(to_lower(c)); + return out; +} + +// =========================================================================== +// LP section-keyword classifiers (case-insensitive; callers pass lowercased) +// =========================================================================== + +bool is_objective_min_keyword(std::string_view lower) +{ + return lower == "minimize" || lower == "minimum" || lower == "min"; +} + +bool is_objective_max_keyword(std::string_view lower) +{ + return lower == "maximize" || lower == "maximum" || lower == "max"; +} + +bool is_bounds_keyword(std::string_view lower) { return lower == "bounds" || lower == "bound"; } + +bool is_generals_keyword(std::string_view lower) +{ + return lower == "generals" || lower == "general" || lower == "gen" || lower == "integer" || + lower == "integers"; +} + +bool is_binaries_keyword(std::string_view lower) +{ + return lower == "binaries" || lower == "binary" || lower == "bin"; +} + +bool is_end_keyword(std::string_view lower) { return lower == "end"; } + +bool is_free_keyword(std::string_view lower) { return lower == "free"; } + +bool is_infinity_text(std::string_view lower) { return lower == "inf" || lower == "infinity"; } + +// Builds the symmetric Q in CSR from LP-format raw upper-triangular triples. +// Each input triple (i, j, c) with i <= j represents `c * x_i * x_j` in the +// LP source. The output Q satisfies x^T Q x = sum of those terms. +// Diagonal (i == j): Q[i,i] = c (one entry). +// Off-diagonal (i != j): Q[i,j] = Q[j,i] = c/2 (two entries; symmetric split). +template +void build_symmetric_q_csr(const std::vector>& raw_triples, + i_t n_vars, + std::vector& out_values, + std::vector& out_indices, + std::vector& out_offsets) +{ + std::vector>> row_data(n_vars); + for (const auto& [i, j, c] : raw_triples) { + if (i == j) { + row_data[i].emplace_back(i, c); + } else { + row_data[i].emplace_back(j, c / f_t(2)); + row_data[j].emplace_back(i, c / f_t(2)); + } + } + for (auto& row : row_data) { + std::sort(row.begin(), row.end()); + } + out_offsets.clear(); + out_indices.clear(); + out_values.clear(); + out_offsets.reserve(static_cast(n_vars) + 1); + out_offsets.push_back(0); + for (i_t r = 0; r < n_vars; ++r) { + for (const auto& [col, val] : row_data[r]) { + out_values.push_back(val); + out_indices.push_back(col); + } + out_offsets.push_back(static_cast(out_values.size())); + } +} + +// =========================================================================== +// Token stream +// =========================================================================== + +// Kinds of tokens produced by the LP tokenizer. The grammar is small enough +// that a hand-written scanner is easier to follow than a regex engine. +enum class LpTokenKind { + Number, // 12, -3.5, 1e-6 + Name, // variable names and section keywords (also the literal "inf") + Plus, // + + Minus, // - + Star, // * + Caret, // ^ + Slash, // / + LessEq, // <= (and < treated as <=) + GreaterEq, // >= (and > treated as >=) + Equal, // = + LBracket, // [ + RBracket, // ] + Colon, // : + Eof, +}; + +struct LpToken { + LpTokenKind kind; + // Owned copy of the token text so the token stream is independent of the + // backing file buffer. + std::string text; + int line; + // True when this is the first non-whitespace/non-comment token on its line. + // Used to detect section headers without emitting newline tokens. + bool is_line_start; +}; + +// =========================================================================== +// Parsing engine — holds all transient parsing state and writes directly +// into the lp_parser_t's public fields. Strictly internal to this TU. +// =========================================================================== + +template +class LpParseEngine { + public: + LpParseEngine(lp_parser_t& out, const std::string& file); + // Parses `text` directly (used by parse_lp_from_string()). + LpParseEngine(lp_parser_t& out, std::string_view text); + + private: + lp_parser_t& out_; + std::vector tokens_; + size_t tok_pos_{0}; + + std::unordered_map var_names_map_{}; + std::unordered_map row_names_map_{}; + std::unordered_set bounds_defined_for_var_id_{}; + // Variables for which a lower bound was set explicitly in the Bounds + // section (via 'x >= lb', 'x = v', 'x free', or 'lb <= x ...'). Used to + // reject 'x <= -1' forms with no paired lower bound: the default lower of + // 0 would collide with the negative upper and silently make the variable + // infeasible. + std::unordered_set lower_explicitly_set_{}; + // Counter used to generate row names for unlabeled constraints (R0, R1, ...). + i_t anon_row_counter_{0}; + + // File → token stream. + void read_and_tokenize(const std::string& file); + void tokenize(const std::string& text); + + // Token stream helpers. + const LpToken& peek(size_t lookahead = 0) const; + const LpToken& advance(); + bool at_eof() const; + bool match(LpTokenKind kind); + void expect(LpTokenKind kind, const char* context); + static bool name_equals_ci(const LpToken& tok, std::string_view lower); + bool is_infinity_keyword(const LpToken& tok) const; + f_t number_from_text(const std::string& text) const; + + // Variable bookkeeping. + i_t get_or_add_var(std::string_view name); + + // Top-level dispatch. + void parse_all(); + + // Section parsers. + void parse_objective_section(); + void parse_constraints_section(); + void parse_bounds_section(); + void parse_integer_list_section(bool is_binary); + void parse_semi_continuous_section(); + + // Expression parsers. + struct LinearTerm { + i_t var_id; + f_t coeff; + }; + void parse_linear_expression(std::vector& out_terms, f_t& out_constant); + + // Where a quadratic '[ ... ]' bracket appears. The two roles differ in + // post-processing: + // Objective: must be followed by '/ 2'; the inner-coefficient convention + // is QUADOBJ-style 0.5 x^T Q x, so off-diagonals are halved + // and linear-inside-bracket terms also get /2. + // Constraint: must NOT be followed by '/ 2'; coefficients are taken at + // face value (x^T Q x); bracket must contain at least one + // quadratic term. + enum class BracketRole { Objective, Constraint }; + void parse_quadratic_bracket(std::vector& out_linear, + int outer_sign, + BracketRole role, + std::vector>& out_quad_entries); + + // Atomic readers. + f_t parse_signed_number(); + + // Section header classification. + enum class SectionKind { + None, + Objective, + Constraints, + Bounds, + Generals, + Binaries, + SemiContinuous, + End, + }; + SectionKind try_consume_section_header(); + void reject_unsupported_section(); + bool at_section_boundary() const; +}; + +// ---- Constructor ---------------------------------------------------------- + +template +LpParseEngine::LpParseEngine(lp_parser_t& out, const std::string& file) + : out_(out) +{ + read_and_tokenize(file); + parse_all(); +} + +template +LpParseEngine::LpParseEngine(lp_parser_t& out, std::string_view text) + : out_(out) +{ + // Skip read_and_tokenize: the caller already supplied the LP text. + // Make a contiguous null-terminated string for tokenize(). + std::string buffered(text); + tokenize(buffered); + parse_all(); +} + +// ---- File I/O + tokenizer ------------------------------------------------- + +template +void LpParseEngine::read_and_tokenize(const std::string& file) +{ + // Delegates to the shared helper so .lp.gz / .lp.bz2 are handled the same + // way as .mps.gz / .mps.bz2 (dlopen-loaded libz / libbz2). The returned + // buffer is null-terminated; strip it before constructing the string view + // since `tokenize` walks the entire string range. + auto buf = detail::file_to_string(file); + std::string text(buf.data(), buf.size() > 0 ? buf.size() - 1 : 0); + tokenize(text); +} + +template +void LpParseEngine::tokenize(const std::string& text) +{ + size_t i = 0; + int line = 1; + bool at_start = true; // next non-whitespace token starts a new line + const size_t n = text.size(); + + auto push = [&](LpTokenKind kind, std::string s) { + tokens_.push_back(LpToken{kind, std::move(s), line, at_start}); + at_start = false; + }; + + while (i < n) { + char c = text[i]; + + if (c == '\n') { + ++line; + at_start = true; + ++i; + continue; + } + if (c == '\r') { + ++i; + continue; + } + if (c == '\\') { // LP comment: '\' through end of line + while (i < n && text[i] != '\n') + ++i; + continue; + } + if (c == ' ' || c == '\t') { + ++i; + continue; + } + + // Single-character punctuation. + switch (c) { + case '+': + push(LpTokenKind::Plus, "+"); + ++i; + continue; + case '-': + push(LpTokenKind::Minus, "-"); + ++i; + continue; + case '*': + push(LpTokenKind::Star, "*"); + ++i; + continue; + case '^': + push(LpTokenKind::Caret, "^"); + ++i; + continue; + case '/': + push(LpTokenKind::Slash, "/"); + ++i; + continue; + case '[': + push(LpTokenKind::LBracket, "["); + ++i; + continue; + case ']': + push(LpTokenKind::RBracket, "]"); + ++i; + continue; + case ':': + push(LpTokenKind::Colon, ":"); + ++i; + continue; + case '=': + // Accept the swapped spellings: '=<' ≡ '<=' and '=>' ≡ '>='. + if (i + 1 < n && text[i + 1] == '<') { + push(LpTokenKind::LessEq, "=<"); + i += 2; + } else if (i + 1 < n && text[i + 1] == '>') { + push(LpTokenKind::GreaterEq, "=>"); + i += 2; + } else { + push(LpTokenKind::Equal, "="); + ++i; + } + continue; + default: break; + } + + // Relation operators. Our LP dialect treats bare '<' as '<=' and bare + // '>' as '>='; we do the same for robustness. + if (c == '<') { + if (i + 1 < n && text[i + 1] == '=') { + push(LpTokenKind::LessEq, "<="); + i += 2; + } else { + push(LpTokenKind::LessEq, "<"); + ++i; + } + continue; + } + if (c == '>') { + if (i + 1 < n && text[i + 1] == '=') { + push(LpTokenKind::GreaterEq, ">="); + i += 2; + } else { + push(LpTokenKind::GreaterEq, ">"); + ++i; + } + continue; + } + + // Numbers: [0-9]+ ('.' [0-9]*)? ([eE] [+-]? [0-9]+)? | '.' [0-9]+ ... + if ((c >= '0' && c <= '9') || + (c == '.' && i + 1 < n && text[i + 1] >= '0' && text[i + 1] <= '9')) { + size_t start = i; + while (i < n && text[i] >= '0' && text[i] <= '9') + ++i; + if (i < n && text[i] == '.') { + ++i; + while (i < n && text[i] >= '0' && text[i] <= '9') + ++i; + } + if (i < n && (text[i] == 'e' || text[i] == 'E')) { + ++i; + if (i < n && (text[i] == '+' || text[i] == '-')) ++i; + mps_parser_expects(i < n && text[i] >= '0' && text[i] <= '9', + error_type_t::ValidationError, + "Malformed number (missing exponent digits) at line %d", + line); + while (i < n && text[i] >= '0' && text[i] <= '9') + ++i; + } + push(LpTokenKind::Number, text.substr(start, i - start)); + continue; + } + + // Names: [A-Za-z_] [A-Za-z0-9_.]* + if (is_name_start_char(c)) { + size_t start = i; + while (i < n && is_name_char(text[i])) + ++i; + push(LpTokenKind::Name, text.substr(start, i - start)); + continue; + } + + mps_parser_expects(false, + error_type_t::ValidationError, + "Unexpected character '%c' (0x%02x) at line %d in LP file", + c, + static_cast(static_cast(c)), + line); + } + + tokens_.push_back(LpToken{LpTokenKind::Eof, "", line, true}); +} + +// ---- Token stream helpers -------------------------------------------------- + +template +const LpToken& LpParseEngine::peek(size_t lookahead) const +{ + size_t idx = tok_pos_ + lookahead; + if (idx >= tokens_.size()) return tokens_.back(); // guaranteed Eof token + return tokens_[idx]; +} + +template +const LpToken& LpParseEngine::advance() +{ + const LpToken& t = tokens_[tok_pos_]; + if (tok_pos_ + 1 < tokens_.size()) ++tok_pos_; + return t; +} + +template +bool LpParseEngine::at_eof() const +{ + return peek().kind == LpTokenKind::Eof; +} + +template +bool LpParseEngine::match(LpTokenKind kind) +{ + if (peek().kind == kind) { + advance(); + return true; + } + return false; +} + +template +void LpParseEngine::expect(LpTokenKind kind, const char* context) +{ + mps_parser_expects(peek().kind == kind, + error_type_t::ValidationError, + "LP parse error at line %d: expected %s, got '%s'", + peek().line, + context, + peek().text.c_str()); + advance(); +} + +template +bool LpParseEngine::name_equals_ci(const LpToken& tok, std::string_view lower) +{ + if (tok.kind != LpTokenKind::Name) return false; + if (tok.text.size() != lower.size()) return false; + for (size_t i = 0; i < tok.text.size(); ++i) { + if (to_lower(tok.text[i]) != lower[i]) return false; + } + return true; +} + +template +bool LpParseEngine::is_infinity_keyword(const LpToken& tok) const +{ + return tok.kind == LpTokenKind::Name && is_infinity_text(lowercase(tok.text)); +} + +template +f_t LpParseEngine::number_from_text(const std::string& text) const +{ + try { + if constexpr (std::is_same_v) { + return std::stof(text); + } else { + return std::stod(text); + } + } catch (...) { + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error: could not parse number '%s'", + text.c_str()); + } + return f_t(0); // unreachable; mps_parser_expects throws +} + +// ---- Variable bookkeeping -------------------------------------------------- + +template +i_t LpParseEngine::get_or_add_var(std::string_view name) +{ + std::string key(name); + auto it = var_names_map_.find(key); + if (it != var_names_map_.end()) return it->second; + i_t id = static_cast(out_.var_names.size()); + out_.var_names.push_back(key); + var_names_map_.emplace(std::move(key), id); + out_.var_types.push_back('C'); + out_.c_values.push_back(f_t(0)); + out_.variable_lower_bounds.push_back(f_t(0)); + out_.variable_upper_bounds.push_back(std::numeric_limits::infinity()); + return id; +} + +// ---- Section header detection --------------------------------------------- + +template +bool LpParseEngine::at_section_boundary() const +{ + if (at_eof()) return true; + const LpToken& t = peek(); + if (!t.is_line_start || t.kind != LpTokenKind::Name) return false; + std::string lower = lowercase(t.text); + + if (is_objective_min_keyword(lower) || is_objective_max_keyword(lower)) return true; + if (is_bounds_keyword(lower)) return true; + if (is_generals_keyword(lower)) return true; + if (is_binaries_keyword(lower)) return true; + if (is_end_keyword(lower)) return true; + + // Multi-word section headers: "Subject To" / "Such That" are supported; + // "Lazy Constraints", "User Cuts", and "General Constraints" are + // recognized as boundaries so the prior section ends cleanly, but + // reject_unsupported_section() throws once dispatch reaches them. + const LpToken& t2 = peek(1); + if (lower == "subject" && name_equals_ci(t2, "to")) return true; + if (lower == "such" && name_equals_ci(t2, "that")) return true; + if (lower == "st" || lower == "st." || lower == "s.t.") return true; + if (lower == "lazy" && name_equals_ci(t2, "constraints")) return true; + if (lower == "user" && name_equals_ci(t2, "cuts")) return true; + if (lower == "general" && name_equals_ci(t2, "constraints")) return true; + + // Semi-Continuous section header (supported); plus other section headers + // that we recognize as boundaries (some supported, some unsupported — + // dispatch decides). + if (lower == "semi" && peek(1).kind == LpTokenKind::Minus && + name_equals_ci(peek(2), "continuous")) + return true; + if (lower == "sos") return true; + if (lower == "pwlobj") return true; + if (lower == "scenarios" || lower == "scenario") return true; + + return false; +} + +template +void LpParseEngine::reject_unsupported_section() +{ + std::string name = peek().text; + std::string lower = lowercase(name); + // Compose a useful display name for multi-word headers. + if (lower == "user" && name_equals_ci(peek(1), "cuts")) { + name = "User Cuts"; + } else if (lower == "lazy" && name_equals_ci(peek(1), "constraints")) { + name = "Lazy Constraints"; + } else if (lower == "general" && name_equals_ci(peek(1), "constraints")) { + name = "General Constraints"; + } + mps_parser_expects(false, + error_type_t::ValidationError, + "LP section '%s' is not supported (scope is LP/MIP/QP only)", + name.c_str()); +} + +template +typename LpParseEngine::SectionKind LpParseEngine::try_consume_section_header() +{ + if (at_eof()) return SectionKind::None; + const LpToken& t = peek(); + mps_parser_expects(t.is_line_start && t.kind == LpTokenKind::Name, + error_type_t::ValidationError, + "LP parse error at line %d: expected section header, got '%s'", + t.line, + t.text.c_str()); + std::string lower = lowercase(t.text); + + if (is_objective_min_keyword(lower)) { + out_.maximize = false; + advance(); + return SectionKind::Objective; + } + if (is_objective_max_keyword(lower)) { + out_.maximize = true; + advance(); + return SectionKind::Objective; + } + if (lower == "subject" && name_equals_ci(peek(1), "to")) { + advance(); + advance(); + return SectionKind::Constraints; + } + if (lower == "such" && name_equals_ci(peek(1), "that")) { + advance(); + advance(); + return SectionKind::Constraints; + } + if (lower == "st" || lower == "st." || lower == "s.t.") { + advance(); + return SectionKind::Constraints; + } + if (is_bounds_keyword(lower)) { + advance(); + return SectionKind::Bounds; + } + if (is_generals_keyword(lower)) { + // "General" alone means Generals; "General Constraints" is unsupported. + if (lower == "general" && name_equals_ci(peek(1), "constraints")) { + reject_unsupported_section(); + } + advance(); + return SectionKind::Generals; + } + if (is_binaries_keyword(lower)) { + advance(); + return SectionKind::Binaries; + } + // "Semi-Continuous" (3 tokens: semi - continuous). + if (lower == "semi" && peek(1).kind == LpTokenKind::Minus && + name_equals_ci(peek(2), "continuous")) { + advance(); + advance(); + advance(); + return SectionKind::SemiContinuous; + } + if (is_end_keyword(lower)) { + advance(); + return SectionKind::End; + } + + // Known unsupported sections → throw with a clear message. + reject_unsupported_section(); + return SectionKind::None; // unreachable +} + +// ---- Expression parsing --------------------------------------------------- + +template +f_t LpParseEngine::parse_signed_number() +{ + int sign = 1; + if (match(LpTokenKind::Minus)) { + sign = -1; + } else { + match(LpTokenKind::Plus); // optional leading '+' + } + if (is_infinity_keyword(peek())) { + advance(); + return sign > 0 ? std::numeric_limits::infinity() : -std::numeric_limits::infinity(); + } + mps_parser_expects(peek().kind == LpTokenKind::Number, + error_type_t::ValidationError, + "LP parse error at line %d: expected a number, got '%s'", + peek().line, + peek().text.c_str()); + f_t val = number_from_text(peek().text); + advance(); + return sign * val; +} + +template +void LpParseEngine::parse_linear_expression(std::vector& out_terms, + f_t& out_constant) +{ + out_constant = f_t(0); + int sign = 1; + bool first = true; + + while (true) { + // A quadratic bracket ends the linear expression. If the bracket is + // preceded by a sign, leave the sign unconsumed so the caller can + // attribute it to the bracket. + if (peek().kind == LpTokenKind::LBracket) break; + if ((peek().kind == LpTokenKind::Plus || peek().kind == LpTokenKind::Minus) && + peek(1).kind == LpTokenKind::LBracket) { + break; + } + + if (peek().kind == LpTokenKind::Plus) { + advance(); + sign = 1; + } else if (peek().kind == LpTokenKind::Minus) { + advance(); + sign = -1; + } else if (!first) { + // No sign between terms → expression ends here. (Relation tokens, + // ']', section headers, EOF all terminate.) + break; + } + + // A term is: (number ('*')?)? varname | number (constant) | varname. + // 'inf' is a bounds-only keyword and never appears here. + f_t coeff = f_t(1); + bool had_coeff = false; + if (peek().kind == LpTokenKind::Number) { + coeff = number_from_text(peek().text); + had_coeff = true; + advance(); + match(LpTokenKind::Star); // optional '*' + } + + if (peek().kind == LpTokenKind::Name && !at_section_boundary() && + !is_free_keyword(lowercase(peek().text)) && !is_infinity_keyword(peek())) { + std::string var_name = peek().text; + advance(); + i_t id = get_or_add_var(var_name); + out_terms.push_back({id, sign * coeff}); + } else if (had_coeff) { + // It was a pure number → contributes to the constant. + out_constant += sign * coeff; + } else { + // Nothing consumed this iteration → not a term, stop. + if (!first) { + // We consumed a sign without a term: malformed. + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error at line %d: expected a term after '+' or '-'", + peek().line); + } + break; + } + + first = false; + sign = 1; + } +} + +template +void LpParseEngine::parse_quadratic_bracket( + std::vector& out_linear, + int outer_sign, + BracketRole role, + std::vector>& out_quad_entries) +{ + expect(LpTokenKind::LBracket, "'[' at start of quadratic section"); + + // Accumulate raw LP-format entries first (diagonal vs off-diagonal), then + // apply the role-specific convention and outer sign after we see the + // closing bracket. + std::vector> raw_quad; + + int sign = 1; + bool first = true; + while (peek().kind != LpTokenKind::RBracket) { + mps_parser_expects(!at_eof(), + error_type_t::ValidationError, + "LP parse error: unterminated quadratic '[' section"); + + if (peek().kind == LpTokenKind::Plus) { + advance(); + sign = 1; + } else if (peek().kind == LpTokenKind::Minus) { + advance(); + sign = -1; + } else if (!first) { + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error at line %d: expected '+' or '-' between " + "quadratic terms, got '%s'", + peek().line, + peek().text.c_str()); + } + + f_t coeff = f_t(1); + if (peek().kind == LpTokenKind::Number) { + coeff = number_from_text(peek().text); + advance(); + match(LpTokenKind::Star); // optional + } + + mps_parser_expects(peek().kind == LpTokenKind::Name, + error_type_t::ValidationError, + "LP parse error at line %d: expected variable name in quadratic term", + peek().line); + std::string var1 = peek().text; + advance(); + i_t i1 = get_or_add_var(var1); + + if (match(LpTokenKind::Caret)) { + // Must be "^ 2". + mps_parser_expects(peek().kind == LpTokenKind::Number && peek().text == "2", + error_type_t::ValidationError, + "LP parse error at line %d: only 'x ^ 2' is supported in quadratic " + "terms (got '%s')", + peek().line, + peek().text.c_str()); + advance(); + raw_quad.emplace_back(i1, i1, sign * coeff); + } else if (match(LpTokenKind::Star)) { + mps_parser_expects(peek().kind == LpTokenKind::Name, + error_type_t::ValidationError, + "LP parse error at line %d: expected variable name after '*' in " + "quadratic cross term", + peek().line); + std::string var2 = peek().text; + advance(); + i_t i2 = get_or_add_var(var2); + // Store in upper-triangular form (i <= j) to match QUADOBJ convention. + i_t a = std::min(i1, i2); + i_t b = std::max(i1, i2); + raw_quad.emplace_back(a, b, sign * coeff); + } else { + // Purely linear term inside the brackets — permitted as long as the + // surrounding /2 convention is respected (the linear term is scaled + // the same way as the quadratic ones). + out_linear.push_back({i1, sign * coeff}); + } + + first = false; + sign = 1; + } + expect(LpTokenKind::RBracket, "closing ']' of quadratic section"); + + const f_t sign_scale = static_cast(outer_sign); + + if (role == BracketRole::Objective) { + // Require the "/ 2" suffix after a quadratic objective expression. + // Without it there is no ambiguity-free way to tell whether the user + // meant /2 and forgot vs. intended bare coefficients, so we enforce the + // stricter form. + mps_parser_expects(peek().kind == LpTokenKind::Slash && peek(1).kind == LpTokenKind::Number && + peek(1).text == "2", + error_type_t::ValidationError, + "LP parse error at line %d: quadratic expression '[ ... ]' in the " + "objective must be followed by '/ 2'", + peek().line); + advance(); // '/' + advance(); // '2' + + // Apply the /2 convention and the QUADOBJ-convention scaling so that + // finalize_problem()'s expansion to full symmetric and *0.5 factor yield + // the right Q for cuOpt's 'x^T Q x' form. + // + // LP term ([...]/2): → quadobj entry + // diagonal c x^2 (actual = c/2) → c + // off-diag c x*y (actual = c/2) → c/2 + for (auto& [a, b, v] : raw_quad) { + if (a != b) { + // off-diagonal: /2 to recover Q[i,j] = Q[j,i] after the later x^T Q x expansion. + v /= f_t(2); + } + out_quad_entries.emplace_back(a, b, sign_scale * v); + } + // Linear terms inside the brackets pick up the /2 scaling and the outer sign. + for (auto& lt : out_linear) + lt.coeff /= f_t(2); + if (outer_sign < 0) { + for (auto& lt : out_linear) + lt.coeff = -lt.coeff; + } + } else { + // Constraint: '/ 2' is forbidden — the LP convention is that constraint + // quadratic brackets carry bare face-value coefficients of x^T Q x. + mps_parser_expects(!(peek().kind == LpTokenKind::Slash && peek(1).kind == LpTokenKind::Number && + peek(1).text == "2"), + error_type_t::ValidationError, + "LP parse error at line %d: quadratic expression '[ ... ]' in a " + "constraint must NOT be followed by '/ 2' (the '/ 2' suffix is " + "reserved for the objective)", + peek().line); + // A bracket containing only linear terms is meaningless in a constraint + // — the user can write the same constraint without the brackets. + mps_parser_expects(!raw_quad.empty(), + error_type_t::ValidationError, + "LP parse error at line %d: quadratic bracket '[ ... ]' in a " + "constraint must contain at least one quadratic term", + peek().line); + + // Coefficients are at face value — the post-pass that flushes the + // quadratic_constraint_block_t to the data model handles the symmetric + // expansion and the /2 split for off-diagonals. + for (auto& [a, b, v] : raw_quad) { + out_quad_entries.emplace_back(a, b, sign_scale * v); + } + if (outer_sign < 0) { + for (auto& lt : out_linear) + lt.coeff = -lt.coeff; + } + } +} + +// ---- Section bodies ------------------------------------------------------- + +template +void LpParseEngine::parse_objective_section() +{ + // Optional "name:" label. + if (peek().kind == LpTokenKind::Name && peek(1).kind == LpTokenKind::Colon && + !at_section_boundary()) { + out_.objective_name = peek().text; + advance(); + advance(); + } + + std::vector linear; + f_t constant = 0; + parse_linear_expression(linear, constant); + + // Optional quadratic bracket, possibly preceded by a sign. In this LP + // dialect the bracket sits inside the objective expression and can + // appear before or after linear terms; we support one bracket followed + // by (possibly) more linear terms. + int quad_sign = 1; + if (peek().kind == LpTokenKind::Plus && peek(1).kind == LpTokenKind::LBracket) { + advance(); + } else if (peek().kind == LpTokenKind::Minus && peek(1).kind == LpTokenKind::LBracket) { + advance(); + quad_sign = -1; + } + if (peek().kind == LpTokenKind::LBracket) { + std::vector in_bracket_linear; + parse_quadratic_bracket( + in_bracket_linear, quad_sign, BracketRole::Objective, out_.quadobj_entries); + for (const auto& lt : in_bracket_linear) + linear.push_back(lt); + + // More linear terms may follow the bracket. + std::vector more; + f_t more_constant = 0; + parse_linear_expression(more, more_constant); + for (const auto& lt : more) + linear.push_back(lt); + constant += more_constant; + } + + // Apply linear terms to the objective vector. Coefficients accumulate in + // case the same variable appears twice. + for (const auto& lt : linear) { + if (static_cast(lt.var_id) >= out_.c_values.size()) { + out_.c_values.resize(lt.var_id + 1, f_t(0)); + } + out_.c_values[lt.var_id] += lt.coeff; + } + // A constant term in the objective becomes the objective offset. + out_.objective_offset_value += constant; +} + +template +void LpParseEngine::parse_constraints_section() +{ + while (!at_section_boundary()) { + // Optional "name:" label — present iff the first two tokens are Name + ':'. + std::string row_name; + if (peek().kind == LpTokenKind::Name && peek(1).kind == LpTokenKind::Colon) { + row_name = peek().text; + advance(); + advance(); + } else { + row_name = "R" + std::to_string(anon_row_counter_++); + } + + std::vector linear; + f_t lhs_constant = 0; + parse_linear_expression(linear, lhs_constant); + + // Optional '+ [ ... ]' or '- [ ... ]' quadratic block in the LHS. + // Mirrors the objective handling; if present, this row becomes a + // quadratic constraint and is stored on quadratic_constraint_blocks + // instead of the linear arrays. + std::vector> qc_triples; + bool is_quadratic_row = false; + int quad_sign = 1; + if (peek().kind == LpTokenKind::Plus && peek(1).kind == LpTokenKind::LBracket) { + advance(); + } else if (peek().kind == LpTokenKind::Minus && peek(1).kind == LpTokenKind::LBracket) { + advance(); + quad_sign = -1; + } + if (peek().kind == LpTokenKind::LBracket) { + is_quadratic_row = true; + std::vector in_bracket_linear; + parse_quadratic_bracket(in_bracket_linear, quad_sign, BracketRole::Constraint, qc_triples); + for (const auto& lt : in_bracket_linear) + linear.push_back(lt); + + // More linear terms may follow the bracket. parse_linear_expression + // does not produce a constant unless the user wrote one in the LHS; + // a constant gets moved to RHS just like the pre-bracket constant. + std::vector more; + f_t more_constant = 0; + parse_linear_expression(more, more_constant); + for (const auto& lt : more) + linear.push_back(lt); + lhs_constant += more_constant; + } + + RowType row_type{}; + if (peek().kind == LpTokenKind::LessEq) { + row_type = LesserThanOrEqual; + advance(); + } else if (peek().kind == LpTokenKind::GreaterEq) { + row_type = GreaterThanOrEqual; + advance(); + } else if (peek().kind == LpTokenKind::Equal) { + row_type = Equality; + advance(); + } else { + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error at line %d: expected a relation operator " + "(<=, >=, =) in constraint, got '%s'", + peek().line, + peek().text.c_str()); + } + + // Quadratic constraints currently only support '≤' in the data model + // (see mps_data_model_t::quadratic_constraint_t docs). + if (is_quadratic_row) { + mps_parser_expects(row_type == LesserThanOrEqual, + error_type_t::ValidationError, + "LP parse error at line %d: quadratic constraint '%s' must use " + "'<=' (only convex '≤' quadratic constraints are supported)", + peek().line, + row_name.c_str()); + } + + f_t rhs_value = parse_signed_number(); + // Any constant that appeared on the LHS is moved to the RHS with a sign flip. + rhs_value -= lhs_constant; + + // Register the row (track name uniqueness regardless of linear/quadratic). + mps_parser_expects(row_names_map_.find(row_name) == row_names_map_.end(), + error_type_t::ValidationError, + "Duplicate constraint name '%s'", + row_name.c_str()); + + // Collect the linear part. Coefficients accumulate for repeated variables; + // sort by var_id for deterministic CSR output. + std::unordered_map row_coeffs; + for (const auto& lt : linear) + row_coeffs[lt.var_id] += lt.coeff; + std::vector> ordered(row_coeffs.begin(), row_coeffs.end()); + std::sort(ordered.begin(), ordered.end()); + std::vector indices; + std::vector values; + indices.reserve(ordered.size()); + values.reserve(ordered.size()); + for (const auto& [vid, val] : ordered) { + if (val == f_t(0)) continue; + indices.push_back(vid); + values.push_back(val); + } + + if (is_quadratic_row) { + // Stash for the post-pass; quadratic rows are *not* added to the + // linear arrays (row_names/row_types/A_indices/A_values/b_values). + // We still record the name in row_names_map_ for uniqueness checks + // — but using a sentinel id below the linear count would be wrong, + // so use a separate sentinel and a placeholder name reservation. + typename lp_parser_t::quadratic_constraint_block_t block; + block.row_name = row_name; + block.row_type = row_type; + block.linear_indices = std::move(indices); + block.linear_values = std::move(values); + block.rhs_value = rhs_value; + block.quad_triples = std::move(qc_triples); + out_.quadratic_constraint_blocks.push_back(std::move(block)); + // Use std::numeric_limits::max() as a sentinel; the map is only + // used for uniqueness, never for index lookup. + row_names_map_.emplace(row_name, std::numeric_limits::max()); + } else { + i_t row_id = static_cast(out_.row_names.size()); + out_.row_names.push_back(row_name); + row_names_map_.emplace(row_name, row_id); + out_.row_types.push_back(row_type); + out_.b_values.push_back(rhs_value); + out_.A_indices.push_back(std::move(indices)); + out_.A_values.push_back(std::move(values)); + } + } +} + +template +void LpParseEngine::parse_bounds_section() +{ + while (!at_section_boundary()) { + // Either starts with a variable name or with a signed number. 'inf' / + // 'infinity' tokens are Names but only valid in the lb-first form. + if (peek().kind == LpTokenKind::Name && !is_infinity_keyword(peek())) { + std::string var_name = peek().text; + advance(); + i_t vid = get_or_add_var(var_name); + bounds_defined_for_var_id_.insert(vid); + + // Suffix after the name. + if (peek().kind == LpTokenKind::Name && is_free_keyword(lowercase(peek().text))) { + advance(); + out_.variable_lower_bounds[vid] = -std::numeric_limits::infinity(); + out_.variable_upper_bounds[vid] = std::numeric_limits::infinity(); + lower_explicitly_set_.insert(vid); + } else if (match(LpTokenKind::LessEq)) { + // x <= ub + out_.variable_upper_bounds[vid] = parse_signed_number(); + } else if (match(LpTokenKind::GreaterEq)) { + // x >= lb + out_.variable_lower_bounds[vid] = parse_signed_number(); + lower_explicitly_set_.insert(vid); + } else if (match(LpTokenKind::Equal)) { + // x = value (fixed) + f_t v = parse_signed_number(); + out_.variable_lower_bounds[vid] = v; + out_.variable_upper_bounds[vid] = v; + lower_explicitly_set_.insert(vid); + } else { + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error at line %d: expected 'free', '<=', '>=' or '=' " + "after variable name in Bounds section, got '%s'", + peek().line, + peek().text.c_str()); + } + } else { + // lb <= x [<= ub] + f_t lb = parse_signed_number(); + expect(LpTokenKind::LessEq, "'<=' in 'lb <= var' bound"); + mps_parser_expects(peek().kind == LpTokenKind::Name && !is_infinity_keyword(peek()), + error_type_t::ValidationError, + "LP parse error at line %d: expected variable name after 'lb <='", + peek().line); + std::string var_name = peek().text; + advance(); + i_t vid = get_or_add_var(var_name); + bounds_defined_for_var_id_.insert(vid); + out_.variable_lower_bounds[vid] = lb; + lower_explicitly_set_.insert(vid); + if (match(LpTokenKind::LessEq)) { out_.variable_upper_bounds[vid] = parse_signed_number(); } + } + } + + // A negative upper bound requires an explicitly stated lower bound, + // otherwise the default lower of 0 would collide with the upper and make + // the variable silently infeasible. Flag this at parse time. + for (i_t vid : bounds_defined_for_var_id_) { + if (out_.variable_upper_bounds[vid] < f_t(0) && !lower_explicitly_set_.count(vid)) { + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error: variable '%s' has a negative upper bound (%g) " + "without an explicit lower bound. Write '-inf <= %s <= %g' or give " + "an explicit lower bound alongside the upper bound.", + out_.var_names[vid].c_str(), + static_cast(out_.variable_upper_bounds[vid]), + out_.var_names[vid].c_str(), + static_cast(out_.variable_upper_bounds[vid])); + } + } +} + +template +void LpParseEngine::parse_integer_list_section(bool is_binary) +{ + while (!at_section_boundary()) { + mps_parser_expects(peek().kind == LpTokenKind::Name, + error_type_t::ValidationError, + "LP parse error at line %d: expected variable name in %s section, got '%s'", + peek().line, + is_binary ? "Binaries" : "Generals", + peek().text.c_str()); + std::string var_name = peek().text; + advance(); + i_t vid = get_or_add_var(var_name); + // Reject if this variable was previously declared semi-continuous; the + // combination is ambiguous (integer vs. continuous-or-zero). + mps_parser_expects(out_.var_types[vid] != 'S', + error_type_t::ValidationError, + "Variable '%s' appears in both Semi-Continuous and %s sections", + var_name.c_str(), + is_binary ? "Binaries" : "Generals"); + out_.var_types[vid] = 'I'; + if (is_binary) { + out_.variable_lower_bounds[vid] = f_t(0); + out_.variable_upper_bounds[vid] = f_t(1); + bounds_defined_for_var_id_.insert(vid); + } + } +} + +template +void LpParseEngine::parse_semi_continuous_section() +{ + while (!at_section_boundary()) { + mps_parser_expects( + peek().kind == LpTokenKind::Name, + error_type_t::ValidationError, + "LP parse error at line %d: expected variable name in Semi-Continuous section, got '%s'", + peek().line, + peek().text.c_str()); + std::string var_name = peek().text; + advance(); + i_t vid = get_or_add_var(var_name); + // Reject if the variable was previously declared integer/binary; the + // combination is ambiguous (integer vs. continuous-or-zero). + mps_parser_expects(out_.var_types[vid] != 'I', + error_type_t::ValidationError, + "Variable '%s' appears in both Generals/Binaries and Semi-Continuous " + "sections", + var_name.c_str()); + out_.var_types[vid] = 'S'; + } +} + +// ---- Top-level dispatch ---------------------------------------------------- + +template +void LpParseEngine::parse_all() +{ + bool saw_objective = false; + bool saw_end = false; + + while (!at_eof()) { + SectionKind kind = try_consume_section_header(); + switch (kind) { + case SectionKind::Objective: + mps_parser_expects(!saw_objective, + error_type_t::ValidationError, + "LP parse error: multiple objective sections"); + parse_objective_section(); + saw_objective = true; + break; + case SectionKind::Constraints: parse_constraints_section(); break; + case SectionKind::Bounds: parse_bounds_section(); break; + case SectionKind::Generals: parse_integer_list_section(false); break; + case SectionKind::Binaries: parse_integer_list_section(true); break; + case SectionKind::SemiContinuous: parse_semi_continuous_section(); break; + case SectionKind::End: + saw_end = true; + break; // Break out of the switch; the check below ends parsing. + case SectionKind::None: break; + } + if (saw_end) break; // Anything after 'End' is ignored. + } + if (!saw_end) { printf("LP parser: 'End' section is missing\n"); } + mps_parser_expects(saw_objective, + error_type_t::ValidationError, + "LP parse error: no objective (Minimize/Maximize) section found"); +} + +} // namespace + +// =========================================================================== +// lp_parser_t — thin public wrapper. All parsing state/types live in the +// anonymous namespace above. +// =========================================================================== + +namespace { + +// Emits one quadratic_constraint_block_t to `problem` via +// append_quadratic_constraint(). Row indices are assigned +// linear_row_count..linear_row_count + nqc - 1, mirroring MPS's QCMATRIX +// handling in mps_parser_t::fill_problem. +template +void flush_quadratic_constraints(mps_data_model_t& problem, + const lp_parser_t& parser) +{ + const i_t n_vars = static_cast(parser.var_names.size()); + const i_t linear_row_count = static_cast(parser.row_names.size()); + i_t k = 0; + for (const auto& block : parser.quadratic_constraint_blocks) { + std::vector q_values; + std::vector q_indices; + std::vector q_offsets; + build_symmetric_q_csr(block.quad_triples, n_vars, q_values, q_indices, q_offsets); + problem.append_quadratic_constraint(linear_row_count + k, + block.row_name, + static_cast(block.row_type), + block.linear_values, + block.linear_indices, + block.rhs_value, + q_values, + q_indices, + q_offsets); + ++k; + } +} + +} // end namespace + +template +lp_parser_t::lp_parser_t(mps_data_model_t& problem, const std::string& file) +{ + LpParseEngine engine(*this, file); + detail::finalize_problem(problem, *this); + flush_quadratic_constraints(problem, *this); +} + +template +lp_parser_t::lp_parser_t(mps_data_model_t& problem, std::string_view input) +{ + LpParseEngine engine(*this, input); + detail::finalize_problem(problem, *this); + flush_quadratic_constraints(problem, *this); +} + +template class lp_parser_t; +template class lp_parser_t; + +// =========================================================================== +// Public parse_lp() / parse_lp_from_string() +// =========================================================================== + +template +mps_data_model_t parse_lp(const std::string& lp_file_path) +{ + mps_data_model_t problem; + lp_parser_t parser(problem, lp_file_path); + return problem; +} + +template +mps_data_model_t parse_lp_from_string(std::string_view lp_contents) +{ + mps_data_model_t problem; + lp_parser_t parser(problem, lp_contents); + return problem; +} + +template mps_data_model_t parse_lp(const std::string&); +template mps_data_model_t parse_lp(const std::string&); +template mps_data_model_t parse_lp_from_string(std::string_view); +template mps_data_model_t parse_lp_from_string(std::string_view); + +} // namespace cuopt::linear_programming::io diff --git a/cpp/src/io/lp_parser.hpp b/cpp/src/io/lp_parser.hpp new file mode 100644 index 0000000000..b068f7535a --- /dev/null +++ b/cpp/src/io/lp_parser.hpp @@ -0,0 +1,84 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace cuopt::linear_programming::io { + +/** + * @brief Parser for the LP format. + * + * The class is a thin holder for the parsed problem data. All parsing + * machinery (tokenizer, expression/section parsers, token types) lives in + * src/lp_parser.cpp and is never exposed. + * + * The public fields mirror mps_parser_t so the two parsers share a single + * finalization path (see src/parser_finalize.hpp) and so tests and tools + * can introspect the same shape of intermediate data from either parser. + */ +template +class lp_parser_t { + public: + // Parses `file` and populates `problem`. + lp_parser_t(mps_data_model_t& problem, const std::string& file); + + // Parses `input` (LP format text already loaded in memory) and populates + // `problem`. Used by parse_lp_from_string() — compressed inputs are only + // supported via the file-path constructor since compression is detected + // from the path suffix. + lp_parser_t(mps_data_model_t& problem, std::string_view input); + + // Intermediate parsed problem data (mirrors mps_parser_t's public fields). + std::string problem_name{}; + std::vector row_names{}; + std::vector row_types{}; + std::string objective_name{"OBJ"}; + std::vector var_names{}; + std::vector var_types{}; + std::vector> A_indices{}; + std::vector> A_values{}; + std::vector b_values{}; + std::vector c_values{}; + f_t objective_offset_value{0}; + std::vector variable_upper_bounds{}; + std::vector variable_lower_bounds{}; + bool maximize{false}; + // Quadratic objective entries (row, col, value) in upper-triangular + // QUADOBJ convention; finalize_problem() mirrors to the full symmetric + // matrix and applies the *0.5 factor required by cuOpt's x^T Q x form. + std::vector> quadobj_entries{}; + + // Per-row data for constraints whose LHS contains a quadratic bracket. + // These rows do NOT appear in row_names/row_types/A_indices/A_values/ + // b_values — those vectors carry only the linear constraints — and they + // are emitted to the data model after finalize_problem via + // mps_data_model_t::append_quadratic_constraint(), with row indices + // assigned linear_row_count..linear_row_count+nqc (mirroring MPS's + // QCMATRIX handling). + struct quadratic_constraint_block_t { + std::string row_name{}; + RowType row_type{}; + std::vector linear_indices{}; + std::vector linear_values{}; + f_t rhs_value{}; + // Upper-triangular (i <= j) raw triples directly from the LP source + // (face value, no /2). The post-pass mirrors and halves off-diagonals + // to build the symmetric Q in CSR. + std::vector> quad_triples{}; + }; + std::vector quadratic_constraint_blocks{}; +}; + +} // namespace cuopt::linear_programming::io diff --git a/cpp/src/io/mps_parser.cpp b/cpp/src/io/mps_parser.cpp index 61cb1fa314..51527f3dab 100644 --- a/cpp/src/io/mps_parser.cpp +++ b/cpp/src/io/mps_parser.cpp @@ -7,6 +7,7 @@ #include +#include #include #include @@ -20,30 +21,9 @@ #include #include -#ifdef MPS_PARSER_WITH_BZIP2 -#include -#endif // MPS_PARSER_WITH_BZIP2 - -#ifdef MPS_PARSER_WITH_ZLIB -#include -#endif // MPS_PARSER_WITH_ZLIB - -#if defined(MPS_PARSER_WITH_BZIP2) || defined(MPS_PARSER_WITH_ZLIB) -#include -#endif // MPS_PARSER_WITH_BZIP2 || MPS_PARSER_WITH_ZLIB - namespace { using cuopt::linear_programming::io::error_type_t; using cuopt::linear_programming::io::mps_parser_expects; -using cuopt::linear_programming::io::mps_parser_expects_fatal; - -struct FcloseDeleter { - void operator()(FILE* fp) - { - mps_parser_expects_fatal( - fclose(fp) == 0, error_type_t::ValidationError, "Error closing MPS file!"); - } -}; std::vector string_to_buffer(std::string_view input) { @@ -53,163 +33,6 @@ std::vector string_to_buffer(std::string_view input) } } // end namespace -#ifdef MPS_PARSER_WITH_BZIP2 -namespace { -using BZ2_bzReadOpen_t = decltype(&BZ2_bzReadOpen); -using BZ2_bzReadClose_t = decltype(&BZ2_bzReadClose); -using BZ2_bzRead_t = decltype(&BZ2_bzRead); - -std::vector bz2_file_to_string(const std::string& file) -{ - struct DlCloseDeleter { - void operator()(void* fp) - { - mps_parser_expects_fatal( - dlclose(fp) == 0, error_type_t::ValidationError, "Error closing libbz2.so!"); - } - }; - struct BzReadCloseDeleter { - void operator()(void* f) - { - int bzerror; - if (f != nullptr) fptr(&bzerror, f); - mps_parser_expects_fatal( - bzerror == BZ_OK, error_type_t::ValidationError, "Error closing bzip2 file!"); - } - BZ2_bzReadClose_t fptr = nullptr; - }; - - std::unique_ptr lbz2handle{dlopen("libbz2.so", RTLD_LAZY)}; - mps_parser_expects( - lbz2handle != nullptr, - error_type_t::ValidationError, - "Could not open .mps.bz2 file since libbz2.so was not found. In order to open .mps.bz2 files " - "directly, please ensure libbzip2 is installed. Alternatively, decompress the .mps.bz2 file " - "manually and open the uncompressed .mps file. Given path: %s", - file.c_str()); - - BZ2_bzReadOpen_t BZ2_bzReadOpen = - reinterpret_cast(dlsym(lbz2handle.get(), "BZ2_bzReadOpen")); - BZ2_bzReadClose_t BZ2_bzReadClose = - reinterpret_cast(dlsym(lbz2handle.get(), "BZ2_bzReadClose")); - BZ2_bzRead_t BZ2_bzRead = reinterpret_cast(dlsym(lbz2handle.get(), "BZ2_bzRead")); - mps_parser_expects( - BZ2_bzReadOpen != nullptr && BZ2_bzReadClose != nullptr && BZ2_bzRead != nullptr, - error_type_t::ValidationError, - "Error loading libbzip2! Library version might be incompatible. Please decompress the .mps.bz2 " - "file manually and open the uncompressed .mps file. Given path: %s", - file.c_str()); - - std::unique_ptr fp{fopen(file.c_str(), "rb")}; - mps_parser_expects(fp != nullptr, - error_type_t::ValidationError, - "Error opening MPS file! Given path: %s", - file.c_str()); - int bzerror = BZ_OK; - std::unique_ptr bzfile{ - BZ2_bzReadOpen(&bzerror, fp.get(), 0, 0, nullptr, 0), {BZ2_bzReadClose}}; - mps_parser_expects(bzerror == BZ_OK, - error_type_t::ValidationError, - "Could not open bzip2 compressed file! Given path: %s", - file.c_str()); - - std::vector buf; - const size_t readbufsize = 1ull << 24; // 16MiB - just a guess. - std::vector readbuf(readbufsize); - while (bzerror == BZ_OK) { - const size_t bytes_read = BZ2_bzRead(&bzerror, bzfile.get(), readbuf.data(), readbuf.size()); - if (bzerror == BZ_OK || bzerror == BZ_STREAM_END) { - buf.insert(buf.end(), begin(readbuf), begin(readbuf) + bytes_read); - } - } - buf.push_back('\0'); - mps_parser_expects(bzerror == BZ_STREAM_END, - error_type_t::ValidationError, - "Error in bzip2 decompression of MPS file! Given path: %s", - file.c_str()); - return buf; -} -} // end namespace -#endif // MPS_PARSER_WITH_BZIP2 - -#ifdef MPS_PARSER_WITH_ZLIB -namespace { -using gzopen_t = decltype(&gzopen); -using gzclose_r_t = decltype(&gzclose_r); -using gzbuffer_t = decltype(&gzbuffer); -using gzread_t = decltype(&gzread); -using gzerror_t = decltype(&gzerror); -std::vector zlib_file_to_string(const std::string& file) -{ - struct DlCloseDeleter { - void operator()(void* fp) - { - mps_parser_expects_fatal( - dlclose(fp) == 0, error_type_t::ValidationError, "Error closing libbz2.so!"); - } - }; - struct GzCloseDeleter { - void operator()(gzFile_s* f) - { - int err = fptr(f); - mps_parser_expects_fatal( - err == Z_OK, error_type_t::ValidationError, "Error closing gz file!"); - } - gzclose_r_t fptr = nullptr; - }; - - std::unique_ptr lzhandle{dlopen("libz.so.1", RTLD_LAZY)}; - mps_parser_expects( - lzhandle != nullptr, - error_type_t::ValidationError, - "Could not open .mps.gz file since libz.so was not found. In order to open .mps.gz files " - "directly, please ensure zlib is installed. Alternatively, decompress the .mps.gz file " - "manually and open the uncompressed .mps file. Given path: %s", - file.c_str()); - gzopen_t gzopen = reinterpret_cast(dlsym(lzhandle.get(), "gzopen")); - gzclose_r_t gzclose_r = reinterpret_cast(dlsym(lzhandle.get(), "gzclose_r")); - gzbuffer_t gzbuffer = reinterpret_cast(dlsym(lzhandle.get(), "gzbuffer")); - gzread_t gzread = reinterpret_cast(dlsym(lzhandle.get(), "gzread")); - gzerror_t gzerror = reinterpret_cast(dlsym(lzhandle.get(), "gzerror")); - mps_parser_expects( - gzopen != nullptr && gzclose_r != nullptr && gzbuffer != nullptr && gzread != nullptr && - gzerror != nullptr, - error_type_t::ValidationError, - "Error loading zlib! Library version might be incompatible. Please decompress the .mps.gz file " - "manually and open the uncompressed .mps file. Given path: %s", - file.c_str()); - std::unique_ptr gzfp{gzopen(file.c_str(), "rb"), {gzclose_r}}; - mps_parser_expects(gzfp != nullptr, - error_type_t::ValidationError, - "Error opening compressed MPS file! Given path: %s", - file.c_str()); - int zlib_status = gzbuffer(gzfp.get(), 1 << 20); // 1 MiB - mps_parser_expects(zlib_status == Z_OK, - error_type_t::ValidationError, - "Could not set zlib internal buffer size for decompression! Given path: %s", - file.c_str()); - std::vector buf; - const size_t readbufsize = 1ull << 24; // 16MiB - std::vector readbuf(readbufsize); - int bytes_read = -1; - while (bytes_read != 0) { - bytes_read = gzread(gzfp.get(), readbuf.data(), readbuf.size()); - if (bytes_read > 0) { buf.insert(buf.end(), begin(readbuf), begin(readbuf) + bytes_read); } - if (bytes_read < 0) { - gzerror(gzfp.get(), &zlib_status); - break; - } - } - buf.push_back('\0'); - mps_parser_expects(zlib_status == Z_OK, - error_type_t::ValidationError, - "Error in zlib decompression of MPS file! Given path: %s", - file.c_str()); - return buf; -} -} // end namespace -#endif // MPS_PARSER_WITH_ZLIB - namespace cuopt::linear_programming::io { template @@ -598,51 +421,6 @@ void mps_parser_t::fill_problem(mps_data_model_t& problem) } } -template -std::vector mps_parser_t::file_to_string(const std::string& file) -{ - // raft::common::nvtx::range fun_scope("file to string"); - -#ifdef MPS_PARSER_WITH_BZIP2 - if (file.size() > 4 && file.substr(file.size() - 4, 4) == ".bz2") { - return bz2_file_to_string(file); - } -#endif // MPS_PARSER_WITH_BZIP2 - -#ifdef MPS_PARSER_WITH_ZLIB - if (file.size() > 3 && file.substr(file.size() - 3, 3) == ".gz") { - return zlib_file_to_string(file); - } -#endif // MPS_PARSER_WITH_ZLIB - - // Faster than using C++ I/O - std::unique_ptr fp{fopen(file.c_str(), "r")}; - mps_parser_expects(fp != nullptr, - error_type_t::ValidationError, - "Error opening MPS file! Given path: %s", - mps_file.c_str()); - - mps_parser_expects(fseek(fp.get(), 0L, SEEK_END) == 0, - error_type_t::ValidationError, - "File browsing MPS file! Given path: %s", - mps_file.c_str()); - const long bufsize = ftell(fp.get()); - mps_parser_expects(bufsize != -1L, - error_type_t::ValidationError, - "File browsing MPS file! Given path: %s", - mps_file.c_str()); - std::vector buf(bufsize + 1); - rewind(fp.get()); - - mps_parser_expects(fread(buf.data(), sizeof(char), bufsize, fp.get()) == bufsize, - error_type_t::ValidationError, - "Error reading MPS file! Given path: %s", - mps_file.c_str()); - buf[bufsize] = '\0'; - - return buf; -} - template void mps_parser_t::parse_string(char* buf) { @@ -914,7 +692,7 @@ mps_parser_t::mps_parser_t(mps_data_model_t& problem, { // raft::common::nvtx::range fun_scope("mps parser"); - std::vector buf = file_to_string(file); + std::vector buf = detail::file_to_string(file); parse_string(buf.data()); diff --git a/cpp/src/io/mps_parser_internal.hpp b/cpp/src/io/mps_parser_internal.hpp index f0cc1d6c05..af27083fcb 100644 --- a/cpp/src/io/mps_parser_internal.hpp +++ b/cpp/src/io/mps_parser_internal.hpp @@ -163,12 +163,6 @@ class mps_parser_t { std::unordered_set lower_bounds_defined_for_var_id{}; static constexpr f_t unset_range_value = std::numeric_limits::infinity(); - /* Reads an MPS input file into a buffer. - * - * If the file has a .gz or .bz2 suffix and zlib or libbzip2 are installed, respectively, - * the function directly reads and decompresses the compressed MPS file. - */ - std::vector file_to_string(const std::string& file); void fill_problem(mps_data_model_t& problem); void parse_string(char* buf); void parse_rows(std::string_view line); diff --git a/cpp/src/io/parser_finalize.hpp b/cpp/src/io/parser_finalize.hpp new file mode 100644 index 0000000000..3c9a2ffbe1 --- /dev/null +++ b/cpp/src/io/parser_finalize.hpp @@ -0,0 +1,255 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::linear_programming::io::detail { + +// Consumes the LP parser's intermediate parsed data and populates `problem`. +// +// CSR flatten, row-type → constraint-bound conversion, quadratic objective +// matrix construction, metadata setters. MPS uses its own fill_problem +// because it handles QCMATRIX quadratic constraints; the LP format does not +// support quadratic constraints, so the two finalization paths intentionally +// diverge. +// +// Required fields on `parser`: +// problem_name, objective_name, row_names, row_types, var_names, +// var_types, A_indices, A_values, b_values, c_values, +// variable_lower_bounds, variable_upper_bounds, objective_offset_value, +// maximize, quadobj_entries. +// +// The requires-expression branches for objective_scaling_factor_value, +// ranges_values, and qmatrix_entries are dormant for LP but kept so the +// template would remain reusable if a non-LP caller ever wants them. +template +void finalize_problem(mps_data_model_t& problem, Parser& parser) +{ + const i_t n_vars = static_cast(parser.var_names.size()); + const i_t n_rows = static_cast(parser.row_names.size()); + + // Pad per-variable vectors that may have grown after their initial size + // (e.g., a variable first appeared after c_values was already initialized). + if (static_cast(parser.c_values.size()) < n_vars) parser.c_values.resize(n_vars, f_t(0)); + if (static_cast(parser.variable_lower_bounds.size()) < n_vars) { + parser.variable_lower_bounds.resize(n_vars, f_t(0)); + } + if (static_cast(parser.variable_upper_bounds.size()) < n_vars) { + parser.variable_upper_bounds.resize(n_vars, std::numeric_limits::infinity()); + } + if (static_cast(parser.var_types.size()) < n_vars) parser.var_types.resize(n_vars, 'C'); + + // Flatten the ragged A_indices / A_values into a single CSR. + std::vector offsets; + std::vector indices; + std::vector values; + offsets.reserve(n_rows + 1); + offsets.push_back(0); + for (i_t i = 0; i < n_rows; ++i) { + for (i_t idx : parser.A_indices[i]) + indices.push_back(idx); + for (f_t v : parser.A_values[i]) + values.push_back(v); + offsets.push_back(static_cast(values.size())); + } + problem.set_csr_constraint_matrix(values, indices, offsets); + + mps_parser_expects(indices.size() == values.size(), + error_type_t::ValidationError, + "Constraint matrix nonzero vector (%zu) and column-index vector (%zu) " + "must have the same size.", + indices.size(), + values.size()); + mps_parser_expects(!offsets.empty() && offsets.back() == static_cast(values.size()), + error_type_t::ValidationError, + "CSR offset tail (%d) must equal the nonzero count (%zu).", + offsets.empty() ? 0 : offsets.back(), + values.size()); + + problem.set_constraint_bounds(parser.b_values); + problem.set_objective_coefficients(parser.c_values); + + f_t scaling = f_t(1); + if constexpr (requires { parser.objective_scaling_factor_value; }) { + scaling = parser.objective_scaling_factor_value; + } + problem.set_objective_scaling_factor(scaling); + problem.set_objective_offset(parser.objective_offset_value); + + problem.set_variable_lower_bounds(parser.variable_lower_bounds); + problem.set_variable_upper_bounds(parser.variable_upper_bounds); + + mps_parser_expects( + (problem.get_variable_lower_bounds().size() == problem.get_variable_upper_bounds().size()) && + (problem.get_variable_upper_bounds().size() == problem.get_objective_coefficients().size()), + error_type_t::ValidationError, + "Per-variable vectors are inconsistently sized. objective=%zu, lb=%zu, ub=%zu.", + problem.get_objective_coefficients().size(), + problem.get_variable_lower_bounds().size(), + problem.get_variable_upper_bounds().size()); + + // Semi-continuous variables must have a finite upper bound; otherwise the + // "x = 0 or lb <= x <= ub" semantics collapse to a regular continuous + // variable. Matches the MPS parser's rule. + for (i_t i = 0; i < n_vars; ++i) { + if (parser.var_types[i] == 'S') { + mps_parser_expects(!std::isinf(parser.variable_upper_bounds[i]), + error_type_t::ValidationError, + "Semi-continuous variable '%s' must have a finite upper bound", + parser.var_names[i].c_str()); + } + } + + // Row types + RHS (+ MPS ranges) → explicit constraint lower/upper bounds. + const f_t inf = std::numeric_limits::infinity(); + std::vector clb; + std::vector cub; + clb.reserve(n_rows); + cub.reserve(n_rows); + constexpr bool has_ranges = requires { parser.ranges_values; }; + for (i_t i = 0; i < n_rows; ++i) { + switch (parser.row_types[i]) { + case Equality: + clb.push_back(parser.b_values[i]); + cub.push_back(parser.b_values[i]); + if constexpr (has_ranges) { + if (!parser.ranges_values.empty() && parser.ranges_values[i] != inf) { + mps_parser_expects(!std::isnan(parser.ranges_values[i]), + error_type_t::ValidationError, + "Equality range value %d is NaN", + i); + if (parser.ranges_values[i] < f_t(0)) { + clb.back() += parser.ranges_values[i]; + } else { + cub.back() += parser.ranges_values[i]; + } + } + } + break; + case GreaterThanOrEqual: + clb.push_back(parser.b_values[i]); + cub.push_back(inf); + if constexpr (has_ranges) { + if (!parser.ranges_values.empty() && parser.ranges_values[i] != inf) { + mps_parser_expects(!std::isnan(parser.ranges_values[i]), + error_type_t::ValidationError, + "Greater range value %d is NaN", + i); + cub.back() = clb.back() + std::abs(parser.ranges_values[i]); + } + } + break; + case LesserThanOrEqual: + clb.push_back(-inf); + cub.push_back(parser.b_values[i]); + if constexpr (has_ranges) { + if (!parser.ranges_values.empty() && parser.ranges_values[i] != inf) { + mps_parser_expects(!std::isnan(parser.ranges_values[i]), + error_type_t::ValidationError, + "Lesser range value %d is NaN", + i); + clb.back() = cub.back() - std::abs(parser.ranges_values[i]); + } + } + break; + default: + mps_parser_expects(false, + error_type_t::ValidationError, + "Unsupported row type for row '%s'", + parser.row_names[i].c_str()); + } + mps_parser_expects(!std::isnan(clb.back()) && !std::isnan(cub.back()), + error_type_t::ValidationError, + "Constraint bound for row '%s' is NaN", + parser.row_names[i].c_str()); + } + problem.set_constraint_lower_bounds(clb); + problem.set_constraint_upper_bounds(cub); + + mps_parser_expects( + (problem.get_constraint_lower_bounds().size() == + problem.get_constraint_upper_bounds().size()) && + (problem.get_constraint_upper_bounds().size() == problem.get_constraint_bounds().size()), + error_type_t::ValidationError, + "Per-constraint vectors are inconsistently sized. rhs=%zu, lb=%zu, ub=%zu.", + problem.get_constraint_bounds().size(), + problem.get_constraint_lower_bounds().size(), + problem.get_constraint_upper_bounds().size()); + + problem.set_problem_name(parser.problem_name); + problem.set_objective_name(parser.objective_name); + // Setters take const refs — pass the fields directly to avoid an extra + // temporary copy. + problem.set_variable_names(parser.var_names); + problem.set_variable_types(parser.var_types); + problem.set_row_names(parser.row_names); + std::vector row_types_chars(parser.row_types.size()); + for (size_t i = 0; i < parser.row_types.size(); ++i) { + row_types_chars[i] = static_cast(parser.row_types[i]); + } + problem.set_row_types(row_types_chars); + problem.set_maximize(parser.maximize); + + // Quadratic objective: build a full symmetric Q via double-transpose. + // - QUADOBJ entries are upper-triangular; each off-diagonal entry is + // mirrored to its transpose when assembling. + // - QMATRIX entries are already the full symmetric matrix. + // Every stored value is multiplied by 0.5 to convert from the file's + // '0.5 x^T Q x' convention to cuOpt's 'x^T Q x'. See mps_parser.cpp for + // the original derivation. + auto build_q_csr = [&](const std::vector>& entries, + bool mirror_off_diagonal) { + std::vector>> csc(n_vars); + for (const auto& [row, col, val] : entries) { + csc[col].emplace_back(row, val); + if (mirror_off_diagonal && row != col) { csc[row].emplace_back(col, val); } + } + std::vector>> csr(n_vars); + for (i_t col = 0; col < n_vars; ++col) { + for (const auto& [row, val] : csc[col]) { + csr[row].emplace_back(col, val); + } + } + // Within each row the entries are naturally ordered by column because + // the outer loop above walks columns in ascending order — no sort needed. + std::vector q_values; + std::vector q_indices; + std::vector q_offsets; + q_offsets.reserve(n_vars + 1); + q_offsets.push_back(0); + for (i_t row = 0; row < n_vars; ++row) { + for (const auto& [col, val] : csr[row]) { + q_values.push_back(val * f_t(0.5)); + q_indices.push_back(col); + } + q_offsets.push_back(static_cast(q_values.size())); + } + problem.set_quadratic_objective_matrix(q_values, q_indices, q_offsets); + }; + + if (!parser.quadobj_entries.empty()) { + build_q_csr(parser.quadobj_entries, /*mirror_off_diagonal=*/true); + } else if constexpr (requires { parser.qmatrix_entries; }) { + if (!parser.qmatrix_entries.empty()) { + build_q_csr(parser.qmatrix_entries, /*mirror_off_diagonal=*/false); + } + } +} + +} // namespace cuopt::linear_programming::io::detail diff --git a/cpp/src/io/utilities/cython_mps_parser.cpp b/cpp/src/io/utilities/cython_parser.cpp similarity index 64% rename from cpp/src/io/utilities/cython_mps_parser.cpp rename to cpp/src/io/utilities/cython_parser.cpp index 1c4ae20a27..86bd0bb77d 100644 --- a/cpp/src/io/utilities/cython_mps_parser.cpp +++ b/cpp/src/io/utilities/cython_parser.cpp @@ -6,7 +6,7 @@ /* clang-format on */ #include -#include +#include namespace cuopt { namespace cython { @@ -18,5 +18,12 @@ std::unique_ptr> ca cuopt::linear_programming::io::parse_mps(mps_file_path, fixed_mps_format))); } +std::unique_ptr> call_parse_lp( + const std::string& lp_file_path) +{ + return std::make_unique>( + std::move(cuopt::linear_programming::io::parse_lp(lp_file_path))); +} + } // namespace cython } // namespace cuopt diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index 993a2c039f..42c083fdbf 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -104,16 +104,17 @@ cuopt_int_t cuOptReadProblem(const char* filename, cuOptOptimizationProblem* pro problem_and_stream_view_t* problem_and_stream = new problem_and_stream_view_t(get_memory_backend_type()); std::string filename_str(filename); - bool input_mps_strict = false; std::unique_ptr> mps_data_model_ptr; try { + // Dispatches on file extension; see parse_problem for the enumerated rules. mps_data_model_ptr = std::make_unique>( - parse_mps(filename_str, input_mps_strict)); + parse_problem(filename_str)); } catch (const std::exception& e) { - CUOPT_LOG_INFO("Error parsing MPS file: %s", e.what()); + CUOPT_LOG_INFO("Error parsing input file: %s", e.what()); delete problem_and_stream; - *problem_ptr = nullptr; - if (std::string(e.what()).find("Error opening MPS file") != std::string::npos) { + *problem_ptr = nullptr; + std::string err_msg = e.what(); + if (err_msg.find("Error opening input file") != std::string::npos) { return CUOPT_MPS_FILE_ERROR; } else { return CUOPT_MPS_PARSE_ERROR; diff --git a/cpp/tests/linear_programming/CMakeLists.txt b/cpp/tests/linear_programming/CMakeLists.txt index a4bdbfbb2e..bc057db1e2 100644 --- a/cpp/tests/linear_programming/CMakeLists.txt +++ b/cpp/tests/linear_programming/CMakeLists.txt @@ -16,9 +16,9 @@ ConfigureTest(PDLP_TEST LABELS numopt) # ################################################################################################## -# - MPS parser tests ------------------------------------------------------------------------------- +# - MPS / LP parser tests -------------------------------------------------------------------------- ConfigureTest(MPS_PARSER_TEST - ${CMAKE_CURRENT_SOURCE_DIR}/mps_parser_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/parser_test.cpp LABELS numopt) # ################################################################################################## diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp index 1912b15cb5..2fc9bdbbb2 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -114,6 +115,44 @@ TEST(c_api, burglar) { EXPECT_EQ(burglar_problem(), CUOPT_SUCCESS); } TEST(c_api, test_missing_file) { EXPECT_EQ(test_missing_file(), CUOPT_MPS_FILE_ERROR); } +// Verifies that cuOptReadProblem dispatches to the LP parser when given a +// path with a .lp extension. The input is a minimal LP (1 variable, 1 +// constraint); we just check the round-trip read produces the expected shape. +TEST(c_api, read_lp_file_by_extension) +{ + constexpr const char* lp_text = R"LP( +Minimize + x +Subject To + c1: x >= 2.5 +Bounds + x <= 10 +End +)LP"; + std::filesystem::path lp_path = + std::filesystem::temp_directory_path() / + (std::string{"c_api_read_lp_"} + std::to_string(::getpid()) + ".lp"); + { + std::ofstream out(lp_path); + out << lp_text; + } + + cuOptOptimizationProblem handle = nullptr; + cuopt_int_t status = cuOptReadProblem(lp_path.string().c_str(), &handle); + EXPECT_EQ(status, CUOPT_SUCCESS); + ASSERT_NE(handle, nullptr); + + cuopt_int_t n_vars = 0; + cuopt_int_t n_constrs = 0; + EXPECT_EQ(cuOptGetNumVariables(handle, &n_vars), CUOPT_SUCCESS); + EXPECT_EQ(cuOptGetNumConstraints(handle, &n_constrs), CUOPT_SUCCESS); + EXPECT_EQ(n_vars, 1); + EXPECT_EQ(n_constrs, 1); + + cuOptDestroyProblem(&handle); + std::filesystem::remove(lp_path); +} + TEST(c_api, test_infeasible_problem) { EXPECT_EQ(test_infeasible_problem(), CUOPT_SUCCESS); } TEST(c_api, test_ranged_problem) diff --git a/cpp/tests/linear_programming/mps_parser_test.cpp b/cpp/tests/linear_programming/mps_parser_test.cpp deleted file mode 100644 index 607a22fd1d..0000000000 --- a/cpp/tests/linear_programming/mps_parser_test.cpp +++ /dev/null @@ -1,1401 +0,0 @@ -/* clang-format off */ -/* - * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -/* clang-format on */ - -#include -#include - -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace cuopt::linear_programming::io { - -constexpr double tolerance = 1e-6; - -mps_parser_t read_from_mps(const std::string& file, bool fixed_format = true) -{ - std::string rel_file{}; - // assume relative paths are relative to RAPIDS_DATASET_ROOT_DIR - const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); - rel_file = rapidsDatasetRootDir + "/" + file; - // Empty problem not used in the test - mps_data_model_t problem; - mps_parser_t mps{problem, rel_file, fixed_format}; - return mps; -} - -bool file_exists(const std::string& file) -{ - std::string rel_file{}; - // assume relative paths are relative to RAPIDS_DATASET_ROOT_DIR - const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); - rel_file = rapidsDatasetRootDir + "/" + file; - return std::filesystem::exists(rel_file); -} - -TEST(mps_parser, bad_mps_files) -{ - std::stringstream ss; - static constexpr int NumMpsFiles = 15; - for (int i = 1; i <= NumMpsFiles; ++i) { - ss << "linear_programming/bad-mps-" << i << ".mps"; - // Check if file exists - if (file_exists(ss.str())) ASSERT_THROW(read_from_mps(ss.str()), std::logic_error); - ss.str(std::string{}); - ss.clear(); - } -} - -TEST(mps_parser, good_mps_file_1) -{ - auto mps = read_from_mps("linear_programming/good-mps-1.mps"); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - EXPECT_EQ(10.1, mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); -} - -TEST(mps_parser, good_mps_file_clrf) -{ - auto mps = read_from_mps("linear_programming/good-mps-1-clrf.mps"); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - EXPECT_EQ(10.1, mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); -} - -TEST(mps_parser, good_mps_free_file_clrf) -{ - auto mps = read_from_mps("linear_programming/good-mps-1-clrf.mps", false); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - EXPECT_EQ(10.1, mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); -} - -TEST(mps_parser, good_mps_file_comments) -{ - auto mps = read_from_mps("linear_programming/good-mps-1-comments.mps", false); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(1), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(1), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); -} - -TEST(mps_parser, good_mps_file_no_name) -{ - // Should not throw an error - read_from_mps("linear_programming/good-mps-fixed-no-name.mps"); -} - -TEST(mps_parser, good_mps_file_empty_name) -{ - // Should not throw an error - read_from_mps("linear_programming/good-mps-fixed-empty-name.mps"); -} - -TEST(mps_parser, good_mps_file_2) -{ - auto mps = read_from_mps("linear_programming/good-fixed-mps-2.mps"); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("RO W1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VA R1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - EXPECT_EQ(10.1, mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); -} - -TEST(mps_parser_free_format, free_format_mps_file_1) -{ // tests for arbitrary spacing in rows, column, rhs - auto mps = read_from_mps("linear_programming/free-format-mps-1.mps", false); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - EXPECT_EQ(10.1, mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); - EXPECT_EQ(false, mps.maximize); -} - -TEST(mps_parser_free_format, bad_free_format_mps_with_spaces_in_names) -{ - ASSERT_THROW(read_from_mps("linear_programming/good-fixed-mps-2.mps", false), std::logic_error); -} - -TEST(mps_parser_free_format, bad_mps_files_free_format) -{ - std::stringstream ss; - static constexpr int NumMpsFiles = 13; - for (int i = 1; i <= NumMpsFiles; ++i) { - ss << "linear_programming/bad-mps-" << i << ".mps"; - if (file_exists(ss.str())) ASSERT_THROW(read_from_mps(ss.str(), false), std::logic_error); - ss.str(std::string{}); - ss.clear(); - } -} - -TEST(mps_bounds, up_low_bounds) -{ - auto mps = read_from_mps("linear_programming/lp_model_with_var_bounds.mps", false); - EXPECT_EQ("lp_model_with_var_bounds", mps.problem_name); - - ASSERT_EQ(int(1), mps.row_names.size()); - EXPECT_EQ("con", mps.row_names[0]); - ASSERT_EQ(int(1), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ("OBJ", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("x", mps.var_names[0]); - EXPECT_EQ("y", mps.var_names[1]); - ASSERT_EQ(int(1), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(1), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(1., mps.A_values[0][0]); - EXPECT_EQ(1., mps.A_values[0][1]); - ASSERT_EQ(int(1), mps.b_values.size()); - EXPECT_EQ(3., mps.b_values[0]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(2., mps.c_values[0]); - EXPECT_EQ(-1., mps.c_values[1]); - EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(0., mps.variable_lower_bounds[0]); - EXPECT_EQ(1., mps.variable_lower_bounds[1]); - EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(1., mps.variable_upper_bounds[0]); - EXPECT_EQ(2., mps.variable_upper_bounds[1]); -} - -TEST(mps_bounds, standard_var_bounds_0_inf) -{ - auto mps = read_from_mps("linear_programming/free-format-mps-1.mps", false); - - // standard bounds are 0,inf when no var bounds are specified - EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(0., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[0]); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[1]); -} - -TEST(mps_bounds, only_some_UP_LO_var_bounds) -{ - auto mps = read_from_mps("linear_programming/good-mps-some-var-bounds.mps"); - - // standard bounds are 0,inf when no var bounds are specified - EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(-1., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[0]); - EXPECT_EQ(2., mps.variable_upper_bounds[1]); -} - -TEST(mps_bounds, fixed_var_bound) -{ - auto mps = read_from_mps("linear_programming/good-mps-fixed-var.mps"); - - // standard bounds are 0,inf when no var bounds are specified - EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(2., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(2., mps.variable_upper_bounds[0]); - EXPECT_EQ(std ::numeric_limits::infinity(), mps.variable_upper_bounds[1]); -} - -TEST(mps_bounds, free_var_bound) -{ - auto mps = read_from_mps("linear_programming/good-mps-free-var.mps"); - - // standard bounds are 0,inf when no var bounds are specified - EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(-std::numeric_limits::infinity(), mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[0]); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[1]); -} - -TEST(mps_bounds, lower_inf_var_bound) -{ - auto mps = read_from_mps("linear_programming/good-mps-lower-bound-inf-var.mps"); - - // standard bounds are 0,inf when no var bounds are specified - EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(-std::numeric_limits::infinity(), mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[0]); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[1]); -} - -TEST(mps_bounds, rhs_cost) -{ - auto mps = read_from_mps("linear_programming/good-mps-rhs-cost.mps"); - - // objective value offset should be set to -5 - EXPECT_EQ(int(-5), mps.objective_offset_value); -} - -TEST(mps_bounds, upper_inf_var_bound) -{ - auto mps = read_from_mps("linear_programming/good-mps-upper-bound-inf-var.mps"); - - // standard bounds are 0,inf when no var bounds are specified - EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(0., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[0]); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[1]); -} - -TEST(mps_bounds, semi_continuous_var_bounds_from_dataset) -{ - struct Case { - const char* name; - const char* mps; - int n_vars; - double lower; - double upper; - }; - const std::vector cases = { - {"sc_standard", cuopt::test::inline_mps::sc_standard_mps, 2, 2.0, 10.0}, - {"sc_lb_zero", cuopt::test::inline_mps::sc_lb_zero_mps, 2, 0.0, 10.0}, - {"sc_no_ub", cuopt::test::inline_mps::sc_no_ub_mps, 2, 2.0, 1e30}, - }; - - for (const auto& c : cases) { - SCOPED_TRACE(c.name); - auto mps = cuopt::test::inline_mps::parse_inline_mps(c.mps); - const auto& var_types = mps.get_variable_types(); - const auto& lower = mps.get_variable_lower_bounds(); - const auto& upper = mps.get_variable_upper_bounds(); - - ASSERT_EQ(c.n_vars, static_cast(var_types.size())); - EXPECT_EQ('S', var_types[0]); - ASSERT_EQ(c.n_vars, static_cast(lower.size())); - ASSERT_EQ(c.n_vars, static_cast(upper.size())); - EXPECT_DOUBLE_EQ(c.lower, lower[0]); - EXPECT_DOUBLE_EQ(c.upper, upper[0]); - } -} - -TEST(mps_bounds, semi_continuous_missing_lower_defaults_to_zero) -{ - auto mps = cuopt::test::inline_mps::parse_inline_mps(cuopt::test::inline_mps::sc_lb_zero_mps); - const auto& var_types = mps.get_variable_types(); - const auto& lower = mps.get_variable_lower_bounds(); - const auto& upper = mps.get_variable_upper_bounds(); - - ASSERT_EQ(2, static_cast(var_types.size())); - EXPECT_EQ('S', var_types[0]); - ASSERT_EQ(2, static_cast(lower.size())); - ASSERT_EQ(2, static_cast(upper.size())); - EXPECT_DOUBLE_EQ(0.0, lower[0]); - EXPECT_DOUBLE_EQ(10.0, upper[0]); -} - -TEST(mps_bounds, semi_continuous_missing_upper_rejected) -{ - EXPECT_THROW( - cuopt::test::inline_mps::parse_inline_mps(cuopt::test::inline_mps::sc_missing_upper_mps), - std::logic_error); -} - -TEST(mps_ranges, fixed_ranges) -{ - std::string file = "linear_programming/good-mps-fixed-ranges.mps"; - auto mps = read_from_mps(file); - - EXPECT_NEAR(4.2, mps.ranges_values[0], tolerance); // ROW1 range value - EXPECT_NEAR(3.4, mps.ranges_values[1], tolerance); // ROW2 range value - EXPECT_NEAR(-1.6, mps.ranges_values[2], tolerance); // ROW3 range value - EXPECT_NEAR(3.4, mps.ranges_values[3], tolerance); // ROW3 range value - - std::string rel_file{}; - const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); - rel_file = rapidsDatasetRootDir + "/" + file; - auto data_model = parse_mps(rel_file, true); - - EXPECT_NEAR(1.2, data_model.get_constraint_lower_bounds()[0], tolerance); // ROW1 lower bound - EXPECT_NEAR(5.4, data_model.get_constraint_upper_bounds()[0], tolerance); // ROW1 upper bound - EXPECT_NEAR(1.5, data_model.get_constraint_lower_bounds()[1], tolerance); // ROW2 lower bound - EXPECT_NEAR(4.9, data_model.get_constraint_upper_bounds()[1], tolerance); // ROW2 upper bound - EXPECT_NEAR( - 7.9, data_model.get_constraint_lower_bounds()[2], tolerance); // ROW3, equal constraint - EXPECT_NEAR( - 9.5, data_model.get_constraint_upper_bounds()[2], tolerance); // ROW3, equal constraint - EXPECT_NEAR( - 3.5, data_model.get_constraint_lower_bounds()[3], tolerance); // ROW4, equal constraint - EXPECT_NEAR( - 6.9, data_model.get_constraint_upper_bounds()[3], tolerance); // ROW4, equal constraint - EXPECT_NEAR(3.9, - data_model.get_constraint_lower_bounds()[4], - tolerance); // ROW5, lower turned into equal constraint - EXPECT_NEAR(3.9, - data_model.get_constraint_upper_bounds()[4], - tolerance); // ROW5, lower turned into equal constraint - EXPECT_NEAR(4.9, - data_model.get_constraint_lower_bounds()[5], - tolerance); // ROW6, greater turned into equal constraint - EXPECT_NEAR(4.9, - data_model.get_constraint_upper_bounds()[5], - tolerance); // ROW6, greater turned into equal constraint -} - -TEST(mps_ranges, free_ranges) -{ - std::string file = "linear_programming/good-mps-free-ranges.mps"; - auto mps = read_from_mps(file, false); - - EXPECT_NEAR(4.2, mps.ranges_values[0], tolerance); // ROW1 range value - EXPECT_NEAR(3.4, mps.ranges_values[1], tolerance); // ROW2 range value - EXPECT_NEAR(-1.6, mps.ranges_values[2], tolerance); // ROW3 range value - EXPECT_NEAR(3.4, mps.ranges_values[3], tolerance); // ROW3 range value - - std::string rel_file{}; - const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); - rel_file = rapidsDatasetRootDir + "/" + file; - auto data_model = parse_mps(rel_file, false); - - EXPECT_NEAR(1.2, data_model.get_constraint_lower_bounds()[0], tolerance); // ROW1 lower bound - EXPECT_NEAR(5.4, data_model.get_constraint_upper_bounds()[0], tolerance); // ROW1 upper bound - EXPECT_NEAR(1.5, data_model.get_constraint_lower_bounds()[1], tolerance); // ROW2 lower bound - EXPECT_NEAR(4.9, data_model.get_constraint_upper_bounds()[1], tolerance); // ROW2 upper bound - EXPECT_NEAR( - 7.9, data_model.get_constraint_lower_bounds()[2], tolerance); // ROW3, equal constraint - EXPECT_NEAR( - 9.5, data_model.get_constraint_upper_bounds()[2], tolerance); // ROW3, equal constraint - EXPECT_NEAR( - 3.5, data_model.get_constraint_lower_bounds()[3], tolerance); // ROW4, equal constraint - EXPECT_NEAR( - 6.9, data_model.get_constraint_upper_bounds()[3], tolerance); // ROW4, equal constraint - EXPECT_NEAR(3.9, - data_model.get_constraint_lower_bounds()[4], - tolerance); // ROW5, lower turned into equal constraint - EXPECT_NEAR(3.9, - data_model.get_constraint_upper_bounds()[4], - tolerance); // ROW5, lower turned into equal constraint - EXPECT_NEAR(4.9, - data_model.get_constraint_lower_bounds()[5], - tolerance); // ROW6, greater turned into equal constraint - EXPECT_NEAR(4.9, - data_model.get_constraint_upper_bounds()[5], - tolerance); // ROW6, greater turned into equal constraint -} - -TEST(mps_name, two_objectives) -{ - std::string file = "linear_programming/good-mps-fixed-two-objectives.mps"; - auto mps = read_from_mps(file, false); - - // Objective name should be first one found and not trigger an error - EXPECT_EQ(mps.objective_name, "COST"); -} - -TEST(mps_objname, two_objectives) -{ - std::string file = "linear_programming/good-mps-fixed-two-objectives-objname.mps"; - auto mps = read_from_mps(file, false); - - // Objective name is the second one found since it's specified as objname - EXPECT_EQ(mps.objective_name, "COST6679327"); -} - -TEST(mps_objname, two_objectives_next_line) -{ - std::string file = "linear_programming/good-mps-fixed-two-objectives-objname-next-line.mps"; - auto mps = read_from_mps(file, false); - - // Objective name is the second one found since it's specified as objname - EXPECT_EQ(mps.objective_name, "COST6679327"); -} - -TEST(mps_objname, bad_after) -{ - std::string file = "linear_programming/bad-mps-fixed-objname-after-rows.mps"; - ASSERT_THROW(read_from_mps(file, false), std::logic_error); -} - -TEST(mps_objname, bad_no_fixed) -{ - std::string file = "linear_programming/bad-mps-fixed-objname-after-rows.mps"; - ASSERT_THROW(read_from_mps(file, true), std::logic_error); -} - -TEST(mps_ranges, bad_name) -{ - ASSERT_THROW(read_from_mps("linear_programming/bad-mps-fixed-ranges-name.mps", false), - std::logic_error); -} - -TEST(mps_ranges, bad_value) -{ - ASSERT_THROW(read_from_mps("linear_programming/bad-mps-fixed-ranges-value.mps", false), - std::logic_error); -} - -TEST(mps_bounds, semi_continuous_bound_type) -{ - auto mps = read_from_mps("linear_programming/good-mps-semi-continuous-bound.mps", false); - - ASSERT_EQ(int(2), mps.var_names.size()); - ASSERT_EQ(int(2), mps.var_types.size()); - EXPECT_EQ('S', mps.var_types[0]); - ASSERT_EQ(int(2), mps.variable_lower_bounds.size()); - ASSERT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_DOUBLE_EQ(0.0, mps.variable_lower_bounds[0]); - EXPECT_DOUBLE_EQ(2.0, mps.variable_upper_bounds[0]); -} - -TEST(mps_bounds, invalid_bound_type) -{ - ASSERT_THROW(read_from_mps("linear_programming/bad-mps-bound-1.mps", false), std::logic_error); -} - -TEST(mps_parser, good_mps_file_mip_1) -{ - auto mps = read_from_mps("mixed_integer_programming/good-mip-mps-1.mps", false); - - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(8000., mps.A_values[0][0]); - EXPECT_EQ(4000., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(15., mps.A_values[1][0]); - EXPECT_EQ(30., mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(40000., mps.b_values[0]); - EXPECT_EQ(200., mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(100., mps.c_values[0]); - EXPECT_EQ(150., mps.c_values[1]); - ASSERT_EQ(int(2), mps.var_types.size()); - EXPECT_EQ('I', mps.var_types[0]); - EXPECT_EQ('I', mps.var_types[1]); - ASSERT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(0., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - ASSERT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(10., mps.variable_upper_bounds[0]); - EXPECT_EQ(10., mps.variable_upper_bounds[1]); -} - -TEST(mps_parser, good_mps_file_mip_no_marker) -{ - auto mps = read_from_mps("mixed_integer_programming/good-mip-mps-1-no-mark.mps", false); - - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(8000., mps.A_values[0][0]); - EXPECT_EQ(4000., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(15., mps.A_values[1][0]); - EXPECT_EQ(30., mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(40000., mps.b_values[0]); - EXPECT_EQ(200., mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(100., mps.c_values[0]); - EXPECT_EQ(150., mps.c_values[1]); - ASSERT_EQ(int(2), mps.var_types.size()); - EXPECT_EQ('I', mps.var_types[0]); - EXPECT_EQ('I', mps.var_types[1]); - ASSERT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(0., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - ASSERT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(10., mps.variable_upper_bounds[0]); - EXPECT_EQ(10., mps.variable_upper_bounds[1]); -} - -TEST(mps_parser, good_mps_file_no_bounds) -{ - auto mps = read_from_mps("mixed_integer_programming/good-mip-mps-no-bounds.mps", false); - - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(8000., mps.A_values[0][0]); - EXPECT_EQ(4000., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(15., mps.A_values[1][0]); - EXPECT_EQ(30., mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(40000., mps.b_values[0]); - EXPECT_EQ(200., mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(100., mps.c_values[0]); - EXPECT_EQ(150., mps.c_values[1]); - ASSERT_EQ(int(2), mps.var_types.size()); - EXPECT_EQ('I', mps.var_types[0]); - EXPECT_EQ('C', mps.var_types[1]); - - ASSERT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(0., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - ASSERT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(1.0, mps.variable_upper_bounds[0]); - EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[1]); -} - -TEST(mps_parser, good_mps_file_partial_bounds) -{ - auto mps = read_from_mps("mixed_integer_programming/good-mip-mps-partial-bounds.mps", false); - - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(8000., mps.A_values[0][0]); - EXPECT_EQ(4000., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(15., mps.A_values[1][0]); - EXPECT_EQ(30., mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(40000., mps.b_values[0]); - EXPECT_EQ(200., mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(100., mps.c_values[0]); - EXPECT_EQ(150., mps.c_values[1]); - ASSERT_EQ(int(2), mps.var_types.size()); - EXPECT_EQ('I', mps.var_types[0]); - EXPECT_EQ('C', mps.var_types[1]); - - ASSERT_EQ(int(2), mps.variable_lower_bounds.size()); - EXPECT_EQ(0., mps.variable_lower_bounds[0]); - EXPECT_EQ(0., mps.variable_lower_bounds[1]); - ASSERT_EQ(int(2), mps.variable_upper_bounds.size()); - EXPECT_EQ(1.0, mps.variable_upper_bounds[0]); - EXPECT_EQ(10.0, mps.variable_upper_bounds[1]); -} - -#ifdef MPS_PARSER_WITH_BZIP2 -TEST(mps_parser, good_mps_file_bzip2_compressed) -{ - auto mps = read_from_mps("linear_programming/good-mps-1.mps.bz2"); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - EXPECT_EQ(10.1, mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); -} -#endif // MPS_PARSER_WITH_BZIP2 - -#ifdef MPS_PARSER_WITH_ZLIB -TEST(mps_parser, good_mps_file_zlib_compressed) -{ - auto mps = read_from_mps("linear_programming/good-mps-1.mps.gz"); - EXPECT_EQ("good-1", mps.problem_name); - ASSERT_EQ(int(2), mps.row_names.size()); - EXPECT_EQ("ROW1", mps.row_names[0]); - EXPECT_EQ("ROW2", mps.row_names[1]); - ASSERT_EQ(int(2), mps.row_types.size()); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); - EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); - EXPECT_EQ("COST", mps.objective_name); - ASSERT_EQ(int(2), mps.var_names.size()); - EXPECT_EQ("VAR1", mps.var_names[0]); - EXPECT_EQ("VAR2", mps.var_names[1]); - ASSERT_EQ(int(2), mps.A_indices.size()); - ASSERT_EQ(int(2), mps.A_indices[0].size()); - EXPECT_EQ(int(0), mps.A_indices[0][0]); - EXPECT_EQ(int(1), mps.A_indices[0][1]); - ASSERT_EQ(int(2), mps.A_indices[1].size()); - EXPECT_EQ(int(0), mps.A_indices[1][0]); - EXPECT_EQ(int(1), mps.A_indices[1][1]); - ASSERT_EQ(int(2), mps.A_values.size()); - ASSERT_EQ(int(2), mps.A_values[0].size()); - EXPECT_EQ(3., mps.A_values[0][0]); - EXPECT_EQ(4., mps.A_values[0][1]); - ASSERT_EQ(int(2), mps.A_values[1].size()); - EXPECT_EQ(2.7, mps.A_values[1][0]); - EXPECT_EQ(10.1, mps.A_values[1][1]); - ASSERT_EQ(int(2), mps.b_values.size()); - EXPECT_EQ(5.4, mps.b_values[0]); - EXPECT_EQ(4.9, mps.b_values[1]); - ASSERT_EQ(int(2), mps.c_values.size()); - EXPECT_EQ(0.2, mps.c_values[0]); - EXPECT_EQ(0.1, mps.c_values[1]); -} -#endif // MPS_PARSER_WITH_ZLIB - -// ================================================================================================ -// QPS (Quadratic Programming) Support Tests -// ================================================================================================ - -// QPS-specific tests for quadratic programming support -TEST(qps_parser, quadratic_objective_basic) -{ - // Create a simple QPS test to verify quadratic objective parsing - // This would require actual QPS test files - for now, test the API - mps_data_model_t model; - - // Test setting quadratic objective matrix - std::vector Q_values = {2.0, 1.0, 1.0, 2.0}; // 2x2 matrix - std::vector Q_indices = {0, 1, 0, 1}; - std::vector Q_offsets = {0, 2, 4}; // CSR offsets - - model.set_quadratic_objective_matrix(Q_values, Q_indices, Q_offsets); - - // Verify the data was stored correctly - EXPECT_TRUE(model.has_quadratic_objective()); - EXPECT_EQ(4, model.get_quadratic_objective_values().size()); - EXPECT_EQ(2.0, model.get_quadratic_objective_values()[0]); - EXPECT_EQ(1.0, model.get_quadratic_objective_values()[1]); -} - -// ================================================================================================ -// QCMATRIX Support Tests -// ================================================================================================ - -TEST(qps_parser, qcmatrix_append_api) -{ - using model_t = mps_data_model_t; - model_t model; - - // Validate default-constructed struct shape. - model_t::quadratic_constraint_t default_qcm; - EXPECT_EQ(0, default_qcm.constraint_row_index); - EXPECT_TRUE(default_qcm.quadratic_values.empty()); - EXPECT_TRUE(default_qcm.quadratic_indices.empty()); - EXPECT_TRUE(default_qcm.quadratic_offsets.empty()); - EXPECT_TRUE(default_qcm.linear_values.empty()); - EXPECT_TRUE(default_qcm.linear_indices.empty()); - EXPECT_EQ(0.0, default_qcm.rhs_value); - - // QC0: [[10, 2], [2, 2]] - const std::vector qc0_values = {10.0, 2.0, 2.0, 2.0}; - const std::vector qc0_indices = {0, 1, 0, 1}; - const std::vector qc0_offsets = {0, 2, 4}; - const std::vector qc0_linear_values = {1.0, 1.0}; - const std::vector qc0_linear_indices = {0, 1}; - model.append_quadratic_constraint(0, - "QC0", - 'L', - qc0_linear_values, - qc0_linear_indices, - 5.0, - qc0_values, - qc0_indices, - qc0_offsets); - - // QC1: [[4, 1], [1, 6]] - const std::vector qc1_values = {4.0, 1.0, 1.0, 6.0}; - const std::vector qc1_indices = {0, 1, 0, 1}; - const std::vector qc1_offsets = {0, 2, 4}; - const std::vector qc1_linear_values = {3.0, 1.0}; - const std::vector qc1_linear_indices = {0, 1}; - model.append_quadratic_constraint(1, - "QC1", - 'L', - qc1_linear_values, - qc1_linear_indices, - 10.0, - qc1_values, - qc1_indices, - qc1_offsets); - - ASSERT_TRUE(model.has_quadratic_constraints()); - const auto& qcs = model.get_quadratic_constraints(); - ASSERT_EQ(2u, qcs.size()); - - EXPECT_EQ(0, qcs[0].constraint_row_index); - EXPECT_EQ("QC0", qcs[0].constraint_row_name); - EXPECT_EQ('L', qcs[0].constraint_row_type); - EXPECT_EQ(qc0_linear_values, qcs[0].linear_values); - EXPECT_EQ(qc0_linear_indices, qcs[0].linear_indices); - EXPECT_EQ(5.0, qcs[0].rhs_value); - EXPECT_EQ(qc0_values, qcs[0].quadratic_values); - EXPECT_EQ(qc0_indices, qcs[0].quadratic_indices); - EXPECT_EQ(qc0_offsets, qcs[0].quadratic_offsets); - - EXPECT_EQ(1, qcs[1].constraint_row_index); - EXPECT_EQ("QC1", qcs[1].constraint_row_name); - EXPECT_EQ('L', qcs[1].constraint_row_type); - EXPECT_EQ(qc1_linear_values, qcs[1].linear_values); - EXPECT_EQ(qc1_linear_indices, qcs[1].linear_indices); - EXPECT_EQ(10.0, qcs[1].rhs_value); - EXPECT_EQ(qc1_values, qcs[1].quadratic_values); - EXPECT_EQ(qc1_indices, qcs[1].quadratic_indices); - EXPECT_EQ(qc1_offsets, qcs[1].quadratic_offsets); -} - -// QCQP MPS: each quadratic constraint bundles row + linear + rhs + quadratic. -TEST(qps_parser, qcmatrix_mps_linear_rhs_and_bounds) -{ - if (!file_exists("qcqp/QC_Test_1.mps")) { - GTEST_SKIP() << "qcqp/QC_Test_1.mps not in dataset root"; - } - const auto model = parse_mps( - cuopt::test::get_rapids_dataset_root_dir() + "/qcqp/QC_Test_1.mps", false); - - ASSERT_TRUE(model.has_quadratic_constraints()); - const auto& qcs = model.get_quadratic_constraints(); - ASSERT_EQ(2u, qcs.size()); - - ASSERT_EQ(1, model.get_n_constraints()); - ASSERT_EQ(1u, model.get_row_names().size()); - EXPECT_EQ("LIN0", model.get_row_names()[0]); - EXPECT_EQ('L', model.get_row_types()[0]); - - // LIN0: 2*x1 + x2 ≤ 15 (linear row only; not duplicated in quadratic_constraints) - EXPECT_DOUBLE_EQ(-std::numeric_limits::infinity(), - model.get_constraint_lower_bounds()[0]); - EXPECT_DOUBLE_EQ(15.0, model.get_constraint_upper_bounds()[0]); - const auto& A_off = model.get_constraint_matrix_offsets(); - const auto& A_val = model.get_constraint_matrix_values(); - const auto& A_idx = model.get_constraint_matrix_indices(); - ASSERT_EQ(2, A_off[1] - A_off[0]); - EXPECT_EQ(2.0, A_val[A_off[0] + 0]); - EXPECT_EQ(1.0, A_val[A_off[0] + 1]); - EXPECT_EQ(0, A_idx[A_off[0] + 0]); - EXPECT_EQ(1, A_idx[A_off[0] + 1]); - - // QC0: x1 + x2 + xᵀQ₀x ≤ 5 (MPS ROWS declaration index 1; OBJ 'N' rows are not counted) - EXPECT_EQ(1, qcs[0].constraint_row_index); - EXPECT_EQ("QC0", qcs[0].constraint_row_name); - EXPECT_EQ('L', qcs[0].constraint_row_type); - ASSERT_EQ(2u, qcs[0].linear_values.size()); - EXPECT_EQ(1.0, qcs[0].linear_values[0]); - EXPECT_EQ(1.0, qcs[0].linear_values[1]); - EXPECT_EQ(0, qcs[0].linear_indices[0]); - EXPECT_EQ(1, qcs[0].linear_indices[1]); - EXPECT_DOUBLE_EQ(5.0, qcs[0].rhs_value); - EXPECT_FALSE(qcs[0].quadratic_values.empty()); - - // QC1: 3*x1 + x2 + xᵀQ₁x ≤ 10 - EXPECT_EQ(2, qcs[1].constraint_row_index); - EXPECT_EQ("QC1", qcs[1].constraint_row_name); - EXPECT_EQ('L', qcs[1].constraint_row_type); - ASSERT_EQ(2u, qcs[1].linear_values.size()); - EXPECT_EQ(3.0, qcs[1].linear_values[0]); - EXPECT_EQ(1.0, qcs[1].linear_values[1]); - EXPECT_DOUBLE_EQ(10.0, qcs[1].rhs_value); -} - -TEST(qps_parser, qcqp_p0033_mps_sections) -{ - if (!file_exists("qcqp/p0033_qc1.mps")) { - GTEST_SKIP() << "qcqp/p0033_qc1.mps not in dataset root"; - } - const auto model = parse_mps( - cuopt::test::get_rapids_dataset_root_dir() + "/qcqp/p0033_qc1.mps", false); - - EXPECT_EQ(12, model.get_n_constraints()); - EXPECT_EQ(33, model.get_n_variables()); - ASSERT_EQ(12u, model.get_row_types().size()); - ASSERT_EQ(12u, model.get_row_names().size()); - - const auto& qcs = model.get_quadratic_constraints(); - ASSERT_EQ(4u, qcs.size()); - EXPECT_EQ(12, qcs[0].constraint_row_index); - ASSERT_EQ(1u, qcs[0].linear_values.size()); - EXPECT_DOUBLE_EQ(1.0, qcs[0].linear_values[0]); - - const auto& vnames = model.get_variable_names(); - auto c159_it = std::find(vnames.begin(), vnames.end(), std::string("C159")); - ASSERT_NE(c159_it, vnames.end()); - EXPECT_EQ(static_cast(c159_it - vnames.begin()), qcs[0].linear_indices[0]); - - EXPECT_DOUBLE_EQ(1.0, qcs[0].rhs_value); - EXPECT_FALSE(qcs[0].quadratic_values.empty()); -} - -// Test actual QPS files from the dataset -TEST(qps_parser, test_qps_files) -{ - // Test QP_Test_1.qps if it exists - if (file_exists("quadratic_programming/QP_Test_1.qps")) { - auto parsed_data = parse_mps( - cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_1.qps", false); - - EXPECT_EQ("QP_Test_1", parsed_data.get_problem_name()); - EXPECT_EQ(2, parsed_data.get_n_variables()); // C------1 and C------2 - EXPECT_EQ(1, parsed_data.get_n_constraints()); // R------1 - EXPECT_TRUE(parsed_data.has_quadratic_objective()); - - // Check variable bounds - const auto& lower_bounds = parsed_data.get_variable_lower_bounds(); - const auto& upper_bounds = parsed_data.get_variable_upper_bounds(); - - EXPECT_NEAR(2.0, lower_bounds[0], tolerance); // C------1 lower bound - EXPECT_NEAR(50.0, upper_bounds[0], tolerance); // C------1 upper bound - EXPECT_NEAR(-50.0, lower_bounds[1], tolerance); // C------2 lower bound - EXPECT_NEAR(50.0, upper_bounds[1], tolerance); // C------2 upper bound - } - - // Test QP_Test_2.qps if it exists - if (file_exists("quadratic_programming/QP_Test_2.qps")) { - auto parsed_data = parse_mps( - cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_2.qps", false); - - EXPECT_EQ("QP_Test_2", parsed_data.get_problem_name()); - EXPECT_EQ(3, parsed_data.get_n_variables()); // C------1, C------2, C------3 - EXPECT_EQ(1, parsed_data.get_n_constraints()); // R------1 - EXPECT_TRUE(parsed_data.has_quadratic_objective()); - - // Check that quadratic objective matrix has values - const auto& Q_values = parsed_data.get_quadratic_objective_values(); - EXPECT_GT(Q_values.size(), 0) << "Quadratic objective should have non-zero elements"; - } -} - -// ================================================================================================ -// MPS Round-Trip Tests (Read -> Write -> Read -> Compare) -// ================================================================================================ - -// Helper function to compare two data models -template -void compare_data_models(const mps_data_model_t& original, - const mps_data_model_t& reloaded, - f_t tol = 1e-9) -{ - // Compare basic dimensions - EXPECT_EQ(original.get_n_variables(), reloaded.get_n_variables()); - EXPECT_EQ(original.get_n_constraints(), reloaded.get_n_constraints()); - - // Compare objective coefficients - auto orig_c = original.get_objective_coefficients(); - auto reload_c = reloaded.get_objective_coefficients(); - ASSERT_EQ(orig_c.size(), reload_c.size()); - for (size_t i = 0; i < orig_c.size(); ++i) { - EXPECT_NEAR(orig_c[i], reload_c[i], tol) << "Objective coefficient mismatch at index " << i; - } - - // Compare constraint matrix values - auto orig_A = original.get_constraint_matrix_values(); - auto reload_A = reloaded.get_constraint_matrix_values(); - ASSERT_EQ(orig_A.size(), reload_A.size()); - for (size_t i = 0; i < orig_A.size(); ++i) { - EXPECT_NEAR(orig_A[i], reload_A[i], tol) << "Constraint matrix value mismatch at index " << i; - } - - // Compare constraint matrix indices - auto orig_A_idx = original.get_constraint_matrix_indices(); - auto reload_A_idx = reloaded.get_constraint_matrix_indices(); - ASSERT_EQ(orig_A_idx.size(), reload_A_idx.size()); - for (size_t i = 0; i < orig_A_idx.size(); ++i) { - EXPECT_EQ(orig_A_idx[i], reload_A_idx[i]) << "Constraint matrix index mismatch at index " << i; - } - - // Compare constraint matrix offsets - auto orig_A_off = original.get_constraint_matrix_offsets(); - auto reload_A_off = reloaded.get_constraint_matrix_offsets(); - ASSERT_EQ(orig_A_off.size(), reload_A_off.size()); - for (size_t i = 0; i < orig_A_off.size(); ++i) { - EXPECT_EQ(orig_A_off[i], reload_A_off[i]) << "Constraint matrix offset mismatch at index " << i; - } - - // Compare variable bounds - auto orig_lb = original.get_variable_lower_bounds(); - auto reload_lb = reloaded.get_variable_lower_bounds(); - ASSERT_EQ(orig_lb.size(), reload_lb.size()); - for (size_t i = 0; i < orig_lb.size(); ++i) { - if (std::isinf(orig_lb[i]) && std::isinf(reload_lb[i])) { - EXPECT_EQ(std::signbit(orig_lb[i]), std::signbit(reload_lb[i])) - << "Variable lower bound infinity sign mismatch at index " << i; - } else { - EXPECT_NEAR(orig_lb[i], reload_lb[i], tol) << "Variable lower bound mismatch at index " << i; - } - } - - auto orig_ub = original.get_variable_upper_bounds(); - auto reload_ub = reloaded.get_variable_upper_bounds(); - ASSERT_EQ(orig_ub.size(), reload_ub.size()); - for (size_t i = 0; i < orig_ub.size(); ++i) { - if (std::isinf(orig_ub[i]) && std::isinf(reload_ub[i])) { - EXPECT_EQ(std::signbit(orig_ub[i]), std::signbit(reload_ub[i])) - << "Variable upper bound infinity sign mismatch at index " << i; - } else { - EXPECT_NEAR(orig_ub[i], reload_ub[i], tol) << "Variable upper bound mismatch at index " << i; - } - } - - // Compare constraint bounds - auto orig_cl = original.get_constraint_lower_bounds(); - auto reload_cl = reloaded.get_constraint_lower_bounds(); - ASSERT_EQ(orig_cl.size(), reload_cl.size()); - for (size_t i = 0; i < orig_cl.size(); ++i) { - if (std::isinf(orig_cl[i]) && std::isinf(reload_cl[i])) { - EXPECT_EQ(std::signbit(orig_cl[i]), std::signbit(reload_cl[i])) - << "Constraint lower bound infinity sign mismatch at index " << i; - } else { - EXPECT_NEAR(orig_cl[i], reload_cl[i], tol) - << "Constraint lower bound mismatch at index " << i; - } - } - - auto orig_cu = original.get_constraint_upper_bounds(); - auto reload_cu = reloaded.get_constraint_upper_bounds(); - ASSERT_EQ(orig_cu.size(), reload_cu.size()); - for (size_t i = 0; i < orig_cu.size(); ++i) { - if (std::isinf(orig_cu[i]) && std::isinf(reload_cu[i])) { - EXPECT_EQ(std::signbit(orig_cu[i]), std::signbit(reload_cu[i])) - << "Constraint upper bound infinity sign mismatch at index " << i; - } else { - EXPECT_NEAR(orig_cu[i], reload_cu[i], tol) - << "Constraint upper bound mismatch at index " << i; - } - } - - // Compare quadratic objective if present - EXPECT_EQ(original.has_quadratic_objective(), reloaded.has_quadratic_objective()); - if (original.has_quadratic_objective() && reloaded.has_quadratic_objective()) { - auto orig_Q = original.get_quadratic_objective_values(); - auto orig_Q_idx = original.get_quadratic_objective_indices(); - auto orig_Q_off = original.get_quadratic_objective_offsets(); - auto reload_Q = reloaded.get_quadratic_objective_values(); - auto reload_Q_idx = reloaded.get_quadratic_objective_indices(); - auto reload_Q_off = reloaded.get_quadratic_objective_offsets(); - - // Compare Q matrix structure and values - ASSERT_EQ(orig_Q.size(), reload_Q.size()) << "Q values size mismatch"; - ASSERT_EQ(orig_Q_idx.size(), reload_Q_idx.size()) << "Q indices size mismatch"; - ASSERT_EQ(orig_Q_off.size(), reload_Q_off.size()) << "Q offsets size mismatch"; - - for (size_t i = 0; i < orig_Q.size(); ++i) { - EXPECT_NEAR(orig_Q[i], reload_Q[i], tol) << "Q value mismatch at index " << i; - } - for (size_t i = 0; i < orig_Q_idx.size(); ++i) { - EXPECT_EQ(orig_Q_idx[i], reload_Q_idx[i]) << "Q index mismatch at index " << i; - } - for (size_t i = 0; i < orig_Q_off.size(); ++i) { - EXPECT_EQ(orig_Q_off[i], reload_Q_off[i]) << "Q offset mismatch at index " << i; - } - } - - EXPECT_EQ(original.has_quadratic_constraints(), reloaded.has_quadratic_constraints()); - if (original.has_quadratic_constraints() && reloaded.has_quadratic_constraints()) { - const auto& oqc = original.get_quadratic_constraints(); - const auto& rq = reloaded.get_quadratic_constraints(); - ASSERT_EQ(oqc.size(), rq.size()) << "Quadratic constraint count mismatch"; - for (size_t k = 0; k < oqc.size(); ++k) { - EXPECT_EQ(oqc[k].constraint_row_index, rq[k].constraint_row_index); - EXPECT_EQ(oqc[k].constraint_row_name, rq[k].constraint_row_name); - EXPECT_EQ(oqc[k].constraint_row_type, rq[k].constraint_row_type); - EXPECT_NEAR(oqc[k].rhs_value, rq[k].rhs_value, tol); - ASSERT_EQ(oqc[k].linear_values.size(), rq[k].linear_values.size()); - ASSERT_EQ(oqc[k].linear_indices.size(), rq[k].linear_indices.size()); - for (size_t i = 0; i < oqc[k].linear_values.size(); ++i) { - EXPECT_NEAR(oqc[k].linear_values[i], rq[k].linear_values[i], tol); - EXPECT_EQ(oqc[k].linear_indices[i], rq[k].linear_indices[i]); - } - ASSERT_EQ(oqc[k].quadratic_values.size(), rq[k].quadratic_values.size()); - ASSERT_EQ(oqc[k].quadratic_indices.size(), rq[k].quadratic_indices.size()); - ASSERT_EQ(oqc[k].quadratic_offsets.size(), rq[k].quadratic_offsets.size()); - for (size_t i = 0; i < oqc[k].quadratic_values.size(); ++i) { - EXPECT_NEAR(oqc[k].quadratic_values[i], rq[k].quadratic_values[i], tol); - } - for (size_t i = 0; i < oqc[k].quadratic_indices.size(); ++i) { - EXPECT_EQ(oqc[k].quadratic_indices[i], rq[k].quadratic_indices[i]); - } - for (size_t i = 0; i < oqc[k].quadratic_offsets.size(); ++i) { - EXPECT_EQ(oqc[k].quadratic_offsets[i], rq[k].quadratic_offsets[i]); - } - } - } -} - -TEST(mps_roundtrip, linear_programming_basic) -{ - std::string input_file = - cuopt::test::get_rapids_dataset_root_dir() + "/linear_programming/good-mps-1.mps"; - std::string temp_file = "/tmp/mps_roundtrip_lp_test.mps"; - - // Read original - auto original = parse_mps(input_file, true); - - // Write to temp file - mps_writer_t writer(original); - writer.write(temp_file); - - // Read back - auto reloaded = parse_mps(temp_file, false); - - // Compare - compare_data_models(original, reloaded); - - // Cleanup - std::filesystem::remove(temp_file); -} - -TEST(mps_roundtrip, linear_programming_with_bounds) -{ - if (!file_exists("linear_programming/lp_model_with_var_bounds.mps")) { - GTEST_SKIP() << "Test file not found"; - } - - std::string input_file = - cuopt::test::get_rapids_dataset_root_dir() + "/linear_programming/lp_model_with_var_bounds.mps"; - std::string temp_file = "/tmp/mps_roundtrip_lp_bounds_test.mps"; - - // Read original - auto original = parse_mps(input_file, false); - - // Write to temp file - mps_writer_t writer(original); - writer.write(temp_file); - - // Read back - auto reloaded = parse_mps(temp_file, false); - - // Compare - compare_data_models(original, reloaded); - - // Cleanup - std::filesystem::remove(temp_file); -} - -TEST(mps_roundtrip, quadratic_programming_qp_test_1) -{ - if (!file_exists("quadratic_programming/QP_Test_1.qps")) { - GTEST_SKIP() << "Test file not found"; - } - - std::string input_file = - cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_1.qps"; - std::string temp_file = "/tmp/mps_roundtrip_qp_test_1.mps"; - - // Read original - auto original = parse_mps(input_file, false); - ASSERT_TRUE(original.has_quadratic_objective()) << "Original should have quadratic objective"; - - // Write to temp file - mps_writer_t writer(original); - writer.write(temp_file); - - // Read back - auto reloaded = parse_mps(temp_file, false); - ASSERT_TRUE(reloaded.has_quadratic_objective()) << "Reloaded should have quadratic objective"; - - // Compare - compare_data_models(original, reloaded); - - // Cleanup - std::filesystem::remove(temp_file); -} - -TEST(mps_roundtrip, quadratic_programming_qp_test_2) -{ - if (!file_exists("quadratic_programming/QP_Test_2.qps")) { - GTEST_SKIP() << "Test file not found"; - } - - std::string input_file = - cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_2.qps"; - std::string temp_file = "/tmp/mps_roundtrip_qp_test_2.mps"; - - // Read original - auto original = parse_mps(input_file, false); - ASSERT_TRUE(original.has_quadratic_objective()) << "Original should have quadratic objective"; - - // Write to temp file - mps_writer_t writer(original); - writer.write(temp_file); - - // Read back - auto reloaded = parse_mps(temp_file, false); - ASSERT_TRUE(reloaded.has_quadratic_objective()) << "Reloaded should have quadratic objective"; - - // Compare - compare_data_models(original, reloaded); - - // Cleanup - std::filesystem::remove(temp_file); -} - -TEST(mps_roundtrip, qcqp_p0033_qc1) -{ - if (!file_exists("qcqp/p0033_qc1.mps")) { GTEST_SKIP() << "Test file not found"; } - - std::string input_file = cuopt::test::get_rapids_dataset_root_dir() + "/qcqp/p0033_qc1.mps"; - std::string temp_file = "/tmp/mps_roundtrip_p0033_qc1.mps"; - std::string temp_file_2 = "/tmp/mps_roundtrip_p0033_qc1_r2.mps"; - - auto original = parse_mps(input_file, false); - ASSERT_TRUE(original.has_quadratic_objective()); - ASSERT_TRUE(original.has_quadratic_constraints()); - - mps_writer_t writer(original); - writer.write(temp_file); - - auto reloaded = parse_mps(temp_file, false); - mps_writer_t writer_r2(reloaded); - writer_r2.write(temp_file_2); - auto reloaded_2 = parse_mps(temp_file_2, false); - compare_data_models(reloaded, reloaded_2); - - std::filesystem::remove(temp_file); - std::filesystem::remove(temp_file_2); -} - -} // namespace cuopt::linear_programming::io diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp new file mode 100644 index 0000000000..0b65c83c38 --- /dev/null +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -0,0 +1,2751 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::linear_programming::io { + +constexpr double tolerance = 1e-6; + +mps_parser_t read_from_mps(const std::string& file, bool fixed_format = true) +{ + std::string rel_file{}; + // assume relative paths are relative to RAPIDS_DATASET_ROOT_DIR + const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); + rel_file = rapidsDatasetRootDir + "/" + file; + // Empty problem not used in the test + mps_data_model_t problem; + mps_parser_t mps{problem, rel_file, fixed_format}; + return mps; +} + +bool file_exists(const std::string& file) +{ + std::string rel_file{}; + // assume relative paths are relative to RAPIDS_DATASET_ROOT_DIR + const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); + rel_file = rapidsDatasetRootDir + "/" + file; + return std::filesystem::exists(rel_file); +} + +namespace { + +// Non-template forwarding wrapper around parse_lp_from_string. +// Exists only so EXPECT_THROW(parse_lp_string(R"LP(...)LP"), exc) is parsed +// correctly — gtest's macro splits its args on top-level commas, and the +// comma inside would otherwise be treated as a macro-arg +// separator. +mps_data_model_t parse_lp_string(std::string_view content) +{ + return parse_lp_from_string(content); +} + +// Returns the index of `name` in the variable list, or -1 if absent. +int find_var(const mps_data_model_t& m, const std::string& name) +{ + const auto& names = m.get_variable_names(); + for (size_t i = 0; i < names.size(); ++i) { + if (names[i] == name) return static_cast(i); + } + return -1; +} + +int find_row(const mps_data_model_t& m, const std::string& name) +{ + const auto& names = m.get_row_names(); + for (size_t i = 0; i < names.size(); ++i) { + if (names[i] == name) return static_cast(i); + } + return -1; +} + +// Returns A[row, col] by scanning the CSR row. Zero if the entry is missing. +double a_entry(const mps_data_model_t& m, int row, int col) +{ + const auto& offsets = m.get_constraint_matrix_offsets(); + const auto& indices = m.get_constraint_matrix_indices(); + const auto& values = m.get_constraint_matrix_values(); + for (int k = offsets[row]; k < offsets[row + 1]; ++k) { + if (indices[k] == col) return values[k]; + } + return 0.0; +} + +// Returns Q[row, col] by scanning the CSR row of the quadratic matrix. +double q_entry(const mps_data_model_t& m, int row, int col) +{ + const auto& offsets = m.get_quadratic_objective_offsets(); + const auto& indices = m.get_quadratic_objective_indices(); + const auto& values = m.get_quadratic_objective_values(); + if (offsets.empty()) return 0.0; + for (int k = offsets[row]; k < offsets[row + 1]; ++k) { + if (indices[k] == col) return values[k]; + } + return 0.0; +} + +} // namespace + +// =========================================================================== +// Per-fixture test classes. Each class describes one named problem fixture +// and owns the checker for that problem's expected parsed data model. The +// MPS and LP TEST_F cases within a fixture share the same `check_model` +// method, so the expected values live in exactly one place per fixture. +// +// All fixtures inherit a common base that supplies parse_mps_file and +// parse_lp_file helpers. +// =========================================================================== + +class parser_fixture_base : public ::testing::Test { + protected: + static mps_data_model_t parse_mps_file(const std::string& file, + bool fixed_format = true) + { + const std::string& root = cuopt::test::get_rapids_dataset_root_dir(); + return parse_mps(root + "/" + file, fixed_format); + } + + static mps_data_model_t parse_lp_file(const std::string& file) + { + const std::string& root = cuopt::test::get_rapids_dataset_root_dir(); + return parse_lp(root + "/" + file); + } +}; + +// 2 vars (continuous, default [0,inf) bounds), 2 <= constraints. +// min 0.2*VAR1 + 0.1*VAR2 +// ROW1: 3*VAR1 + 4*VAR2 <= 5.4 +// ROW2: 2.7*VAR1 + 10.1*VAR2 <= 4.9 +class good_mps_1_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + EXPECT_FALSE(m.get_sense()); + ASSERT_EQ(2, m.get_n_variables()); + ASSERT_EQ(2, m.get_n_constraints()); + EXPECT_EQ("VAR1", m.get_variable_names()[0]); + EXPECT_EQ("VAR2", m.get_variable_names()[1]); + EXPECT_EQ("ROW1", m.get_row_names()[0]); + EXPECT_EQ("ROW2", m.get_row_names()[1]); + EXPECT_EQ('C', m.get_variable_types()[0]); + EXPECT_EQ('C', m.get_variable_types()[1]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[0]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[1]); + EXPECT_NEAR(0.2, m.get_objective_coefficients()[0], tolerance); + EXPECT_NEAR(0.1, m.get_objective_coefficients()[1], tolerance); + EXPECT_EQ(-std::numeric_limits::infinity(), m.get_constraint_lower_bounds()[0]); + EXPECT_NEAR(5.4, m.get_constraint_upper_bounds()[0], tolerance); + EXPECT_EQ(-std::numeric_limits::infinity(), m.get_constraint_lower_bounds()[1]); + EXPECT_NEAR(4.9, m.get_constraint_upper_bounds()[1], tolerance); + const auto& off = m.get_constraint_matrix_offsets(); + const auto& idx = m.get_constraint_matrix_indices(); + const auto& val = m.get_constraint_matrix_values(); + ASSERT_EQ(3u, off.size()); + EXPECT_EQ(0, off[0]); + EXPECT_EQ(2, off[1]); + EXPECT_EQ(4, off[2]); + EXPECT_EQ(0, idx[0]); + EXPECT_NEAR(3.0, val[0], tolerance); + EXPECT_EQ(1, idx[1]); + EXPECT_NEAR(4.0, val[1], tolerance); + EXPECT_EQ(0, idx[2]); + EXPECT_NEAR(2.7, val[2], tolerance); + EXPECT_EQ(1, idx[3]); + EXPECT_NEAR(10.1, val[3], tolerance); + EXPECT_FALSE(m.has_quadratic_objective()); + } +}; + +// min 2x - y; x+y <= 3; 0<=x<=1, 1<=y<=2. +class up_low_bounds_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + EXPECT_FALSE(m.get_sense()); + ASSERT_EQ(2, m.get_n_variables()); + ASSERT_EQ(1, m.get_n_constraints()); + EXPECT_EQ("x", m.get_variable_names()[0]); + EXPECT_EQ("y", m.get_variable_names()[1]); + EXPECT_EQ("con", m.get_row_names()[0]); + EXPECT_NEAR(2.0, m.get_objective_coefficients()[0], tolerance); + EXPECT_NEAR(-1.0, m.get_objective_coefficients()[1], tolerance); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(1.0, m.get_variable_upper_bounds()[0]); + EXPECT_EQ(1.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(2.0, m.get_variable_upper_bounds()[1]); + EXPECT_EQ(-std::numeric_limits::infinity(), m.get_constraint_lower_bounds()[0]); + EXPECT_NEAR(3.0, m.get_constraint_upper_bounds()[0], tolerance); + const auto& val = m.get_constraint_matrix_values(); + ASSERT_EQ(2u, val.size()); + EXPECT_NEAR(1.0, val[0], tolerance); + EXPECT_NEAR(1.0, val[1], tolerance); + } +}; + +// good-mps-1 objective/matrix/rows; -1 <= VAR1 <= inf, 0 <= VAR2 <= 2. +class some_var_bounds_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + ASSERT_EQ(2, m.get_n_variables()); + EXPECT_EQ(-1.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(2.0, m.get_variable_upper_bounds()[1]); + } +}; + +// VAR1 fixed at 2; VAR2 default [0, inf). +class fixed_var_bound_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + ASSERT_EQ(2, m.get_n_variables()); + EXPECT_EQ(2.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(2.0, m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[1]); + } +}; + +// VAR1 free (-inf, +inf); VAR2 default [0, +inf). +class free_var_bound_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + ASSERT_EQ(2, m.get_n_variables()); + EXPECT_EQ(-std::numeric_limits::infinity(), m.get_variable_lower_bounds()[0]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[1]); + } +}; + +// VAR1 lower=-inf (MI in MPS / -inf in LP), upper default +inf; VAR2 default. +// Effective bounds match free_var_bound_test — the two fixtures differ only in +// how the lower -inf is spelled (free vs explicit -inf bound). +class lower_inf_var_bound_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + ASSERT_EQ(2, m.get_n_variables()); + EXPECT_EQ(-std::numeric_limits::infinity(), m.get_variable_lower_bounds()[0]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[1]); + } +}; + +// VAR1 upper=+inf (PL in MPS / inf in LP); both default lower 0. Effective +// bounds match two default [0, +inf) variables. +class upper_inf_var_bound_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + ASSERT_EQ(2, m.get_n_variables()); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[1]); + } +}; + +// 2 integer vars bounded [0, 10]; max 100 VAR1 + 150 VAR2; +// 8000 VAR1 + 4000 VAR2 <= 40000 ; 15 VAR1 + 30 VAR2 <= 200. +class mip_with_bounds_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + EXPECT_TRUE(m.get_sense()); + ASSERT_EQ(2, m.get_n_variables()); + ASSERT_EQ(2, m.get_n_constraints()); + EXPECT_EQ("VAR1", m.get_variable_names()[0]); + EXPECT_EQ("VAR2", m.get_variable_names()[1]); + EXPECT_EQ('I', m.get_variable_types()[0]); + EXPECT_EQ('I', m.get_variable_types()[1]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(10.0, m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(10.0, m.get_variable_upper_bounds()[1]); + EXPECT_NEAR(100.0, m.get_objective_coefficients()[0], tolerance); + EXPECT_NEAR(150.0, m.get_objective_coefficients()[1], tolerance); + EXPECT_EQ(-std::numeric_limits::infinity(), m.get_constraint_lower_bounds()[0]); + EXPECT_NEAR(40000.0, m.get_constraint_upper_bounds()[0], tolerance); + EXPECT_EQ(-std::numeric_limits::infinity(), m.get_constraint_lower_bounds()[1]); + EXPECT_NEAR(200.0, m.get_constraint_upper_bounds()[1], tolerance); + const auto& val = m.get_constraint_matrix_values(); + ASSERT_EQ(4u, val.size()); + EXPECT_NEAR(8000.0, val[0], tolerance); + EXPECT_NEAR(4000.0, val[1], tolerance); + EXPECT_NEAR(15.0, val[2], tolerance); + EXPECT_NEAR(30.0, val[3], tolerance); + } +}; + +// Like mip_with_bounds but VAR1 is binary ([0,1]) and VAR2 is continuous, +// default upper +inf. (MPS: no explicit bounds on integer => [0,1]. LP: VAR1 +// listed under Binaries.) +class mip_no_bounds_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + EXPECT_TRUE(m.get_sense()); + ASSERT_EQ(2, m.get_n_variables()); + EXPECT_EQ('I', m.get_variable_types()[0]); + EXPECT_EQ('C', m.get_variable_types()[1]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(1.0, m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(std::numeric_limits::infinity(), m.get_variable_upper_bounds()[1]); + } +}; + +// VAR1 binary ([0,1]); VAR2 continuous with explicit upper 10. +class mip_partial_bounds_test : public parser_fixture_base { + protected: + static void check_model(const mps_data_model_t& m) + { + EXPECT_TRUE(m.get_sense()); + ASSERT_EQ(2, m.get_n_variables()); + EXPECT_EQ('I', m.get_variable_types()[0]); + EXPECT_EQ('C', m.get_variable_types()[1]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[0]); + EXPECT_EQ(1.0, m.get_variable_upper_bounds()[0]); + EXPECT_EQ(0.0, m.get_variable_lower_bounds()[1]); + EXPECT_EQ(10.0, m.get_variable_upper_bounds()[1]); + } +}; + +TEST(mps_parser, bad_mps_files) +{ + std::stringstream ss; + static constexpr int NumMpsFiles = 15; + for (int i = 1; i <= NumMpsFiles; ++i) { + ss << "linear_programming/bad-mps-" << i << ".mps"; + // Check if file exists + if (file_exists(ss.str())) ASSERT_THROW(read_from_mps(ss.str()), std::logic_error); + ss.str(std::string{}); + ss.clear(); + } +} + +TEST_F(good_mps_1_test, mps) +{ + check_model(parse_mps_file("linear_programming/good-mps-1.mps")); + // Parser-struct fields that are MPS-only (not exposed via the data model). + auto mps = read_from_mps("linear_programming/good-mps-1.mps"); + EXPECT_EQ("good-1", mps.problem_name); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); +} + +TEST_F(good_mps_1_test, lp) { check_model(parse_lp_file("linear_programming/good-mps-1.lp")); } + +// Compressed-LP coverage: parse_lp() shares file_to_string() with parse_mps(), +// so the same dlopen-based decompression path that handles .mps.gz / .mps.bz2 +// must also work for .lp.gz / .lp.bz2. +TEST_F(good_mps_1_test, lp_zlib_compressed) +{ + check_model(parse_lp_file("linear_programming/good-mps-1.lp.gz")); +} + +TEST_F(good_mps_1_test, lp_bzip2_compressed) +{ + check_model(parse_lp_file("linear_programming/good-mps-1.lp.bz2")); +} + +TEST(mps_parser, good_mps_file_clrf) +{ + auto mps = read_from_mps("linear_programming/good-mps-1-clrf.mps"); + EXPECT_EQ("good-1", mps.problem_name); + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("ROW1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VAR1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(2), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + EXPECT_EQ(int(1), mps.A_indices[1][1]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(3., mps.A_values[0][0]); + EXPECT_EQ(4., mps.A_values[0][1]); + ASSERT_EQ(int(2), mps.A_values[1].size()); + EXPECT_EQ(2.7, mps.A_values[1][0]); + EXPECT_EQ(10.1, mps.A_values[1][1]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(5.4, mps.b_values[0]); + EXPECT_EQ(4.9, mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(0.2, mps.c_values[0]); + EXPECT_EQ(0.1, mps.c_values[1]); +} + +TEST(mps_parser, good_mps_free_file_clrf) +{ + auto mps = read_from_mps("linear_programming/good-mps-1-clrf.mps", false); + EXPECT_EQ("good-1", mps.problem_name); + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("ROW1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VAR1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(2), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + EXPECT_EQ(int(1), mps.A_indices[1][1]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(3., mps.A_values[0][0]); + EXPECT_EQ(4., mps.A_values[0][1]); + ASSERT_EQ(int(2), mps.A_values[1].size()); + EXPECT_EQ(2.7, mps.A_values[1][0]); + EXPECT_EQ(10.1, mps.A_values[1][1]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(5.4, mps.b_values[0]); + EXPECT_EQ(4.9, mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(0.2, mps.c_values[0]); + EXPECT_EQ(0.1, mps.c_values[1]); +} + +TEST(mps_parser, good_mps_file_comments) +{ + auto mps = read_from_mps("linear_programming/good-mps-1-comments.mps", false); + EXPECT_EQ("good-1", mps.problem_name); + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("ROW1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VAR1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(1), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(3., mps.A_values[0][0]); + EXPECT_EQ(4., mps.A_values[0][1]); + ASSERT_EQ(int(1), mps.A_values[1].size()); + EXPECT_EQ(2.7, mps.A_values[1][0]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(5.4, mps.b_values[0]); + EXPECT_EQ(4.9, mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(0.2, mps.c_values[0]); + EXPECT_EQ(0.1, mps.c_values[1]); +} + +TEST(mps_parser, good_mps_file_no_name) +{ + // Should not throw an error + read_from_mps("linear_programming/good-mps-fixed-no-name.mps"); +} + +TEST(mps_parser, good_mps_file_empty_name) +{ + // Should not throw an error + read_from_mps("linear_programming/good-mps-fixed-empty-name.mps"); +} + +TEST(mps_parser, good_mps_file_2) +{ + auto mps = read_from_mps("linear_programming/good-fixed-mps-2.mps"); + EXPECT_EQ("good-1", mps.problem_name); + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("RO W1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VA R1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(2), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + EXPECT_EQ(int(1), mps.A_indices[1][1]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(3., mps.A_values[0][0]); + EXPECT_EQ(4., mps.A_values[0][1]); + ASSERT_EQ(int(2), mps.A_values[1].size()); + EXPECT_EQ(2.7, mps.A_values[1][0]); + EXPECT_EQ(10.1, mps.A_values[1][1]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(5.4, mps.b_values[0]); + EXPECT_EQ(4.9, mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(0.2, mps.c_values[0]); + EXPECT_EQ(0.1, mps.c_values[1]); +} + +TEST(mps_parser_free_format, free_format_mps_file_1) +{ // tests for arbitrary spacing in rows, column, rhs + auto mps = read_from_mps("linear_programming/free-format-mps-1.mps", false); + EXPECT_EQ("good-1", mps.problem_name); + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("ROW1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VAR1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(2), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + EXPECT_EQ(int(1), mps.A_indices[1][1]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(3., mps.A_values[0][0]); + EXPECT_EQ(4., mps.A_values[0][1]); + ASSERT_EQ(int(2), mps.A_values[1].size()); + EXPECT_EQ(2.7, mps.A_values[1][0]); + EXPECT_EQ(10.1, mps.A_values[1][1]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(5.4, mps.b_values[0]); + EXPECT_EQ(4.9, mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(0.2, mps.c_values[0]); + EXPECT_EQ(0.1, mps.c_values[1]); + EXPECT_EQ(false, mps.maximize); +} + +TEST(mps_parser_free_format, bad_free_format_mps_with_spaces_in_names) +{ + ASSERT_THROW(read_from_mps("linear_programming/good-fixed-mps-2.mps", false), std::logic_error); +} + +TEST(mps_parser_free_format, bad_mps_files_free_format) +{ + std::stringstream ss; + static constexpr int NumMpsFiles = 13; + for (int i = 1; i <= NumMpsFiles; ++i) { + ss << "linear_programming/bad-mps-" << i << ".mps"; + if (file_exists(ss.str())) ASSERT_THROW(read_from_mps(ss.str(), false), std::logic_error); + ss.str(std::string{}); + ss.clear(); + } +} + +TEST_F(up_low_bounds_test, mps) +{ + check_model(parse_mps_file("linear_programming/lp_model_with_var_bounds.mps", false)); + auto mps = read_from_mps("linear_programming/lp_model_with_var_bounds.mps", false); + EXPECT_EQ("lp_model_with_var_bounds", mps.problem_name); + EXPECT_EQ("OBJ", mps.objective_name); + ASSERT_EQ(int(1), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); +} + +TEST_F(up_low_bounds_test, lp) +{ + check_model(parse_lp_file("linear_programming/lp_model_with_var_bounds.lp")); +} + +TEST_F(good_mps_1_test, mps_free_format) +{ + // free-format-mps-1.mps encodes the same problem as good-mps-1 with default + // [0, +inf) bounds (no BOUNDS section), so it satisfies the same checker. + check_model(parse_mps_file("linear_programming/free-format-mps-1.mps", false)); +} + +TEST_F(some_var_bounds_test, mps) +{ + check_model(parse_mps_file("linear_programming/good-mps-some-var-bounds.mps")); +} + +TEST_F(some_var_bounds_test, lp) +{ + check_model(parse_lp_file("linear_programming/good-mps-some-var-bounds.lp")); +} + +TEST_F(fixed_var_bound_test, mps) +{ + check_model(parse_mps_file("linear_programming/good-mps-fixed-var.mps")); +} + +TEST_F(fixed_var_bound_test, lp) +{ + check_model(parse_lp_file("linear_programming/good-mps-fixed-var.lp")); +} + +TEST_F(free_var_bound_test, mps) +{ + check_model(parse_mps_file("linear_programming/good-mps-free-var.mps")); +} + +TEST_F(free_var_bound_test, lp) +{ + check_model(parse_lp_file("linear_programming/good-mps-free-var.lp")); +} + +TEST_F(lower_inf_var_bound_test, mps) +{ + check_model(parse_mps_file("linear_programming/good-mps-lower-bound-inf-var.mps")); +} + +TEST_F(lower_inf_var_bound_test, lp) +{ + check_model(parse_lp_file("linear_programming/good-mps-lower-bound-inf-var.lp")); +} + +TEST(mps_bounds, rhs_cost) +{ + auto mps = read_from_mps("linear_programming/good-mps-rhs-cost.mps"); + + // objective value offset should be set to -5 + EXPECT_EQ(int(-5), mps.objective_offset_value); +} + +TEST_F(upper_inf_var_bound_test, mps) +{ + check_model(parse_mps_file("linear_programming/good-mps-upper-bound-inf-var.mps")); +} + +TEST_F(upper_inf_var_bound_test, lp) +{ + check_model(parse_lp_file("linear_programming/good-mps-upper-bound-inf-var.lp")); +} + +TEST(mps_ranges, fixed_ranges) +{ + std::string file = "linear_programming/good-mps-fixed-ranges.mps"; + auto mps = read_from_mps(file); + + EXPECT_NEAR(4.2, mps.ranges_values[0], tolerance); // ROW1 range value + EXPECT_NEAR(3.4, mps.ranges_values[1], tolerance); // ROW2 range value + EXPECT_NEAR(-1.6, mps.ranges_values[2], tolerance); // ROW3 range value + EXPECT_NEAR(3.4, mps.ranges_values[3], tolerance); // ROW3 range value + + std::string rel_file{}; + const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); + rel_file = rapidsDatasetRootDir + "/" + file; + auto data_model = parse_mps(rel_file, true); + + EXPECT_NEAR(1.2, data_model.get_constraint_lower_bounds()[0], tolerance); // ROW1 lower bound + EXPECT_NEAR(5.4, data_model.get_constraint_upper_bounds()[0], tolerance); // ROW1 upper bound + EXPECT_NEAR(1.5, data_model.get_constraint_lower_bounds()[1], tolerance); // ROW2 lower bound + EXPECT_NEAR(4.9, data_model.get_constraint_upper_bounds()[1], tolerance); // ROW2 upper bound + EXPECT_NEAR( + 7.9, data_model.get_constraint_lower_bounds()[2], tolerance); // ROW3, equal constraint + EXPECT_NEAR( + 9.5, data_model.get_constraint_upper_bounds()[2], tolerance); // ROW3, equal constraint + EXPECT_NEAR( + 3.5, data_model.get_constraint_lower_bounds()[3], tolerance); // ROW4, equal constraint + EXPECT_NEAR( + 6.9, data_model.get_constraint_upper_bounds()[3], tolerance); // ROW4, equal constraint + EXPECT_NEAR(3.9, + data_model.get_constraint_lower_bounds()[4], + tolerance); // ROW5, lower turned into equal constraint + EXPECT_NEAR(3.9, + data_model.get_constraint_upper_bounds()[4], + tolerance); // ROW5, lower turned into equal constraint + EXPECT_NEAR(4.9, + data_model.get_constraint_lower_bounds()[5], + tolerance); // ROW6, greater turned into equal constraint + EXPECT_NEAR(4.9, + data_model.get_constraint_upper_bounds()[5], + tolerance); // ROW6, greater turned into equal constraint +} + +TEST(mps_ranges, free_ranges) +{ + std::string file = "linear_programming/good-mps-free-ranges.mps"; + auto mps = read_from_mps(file, false); + + EXPECT_NEAR(4.2, mps.ranges_values[0], tolerance); // ROW1 range value + EXPECT_NEAR(3.4, mps.ranges_values[1], tolerance); // ROW2 range value + EXPECT_NEAR(-1.6, mps.ranges_values[2], tolerance); // ROW3 range value + EXPECT_NEAR(3.4, mps.ranges_values[3], tolerance); // ROW3 range value + + std::string rel_file{}; + const std::string& rapidsDatasetRootDir = cuopt::test::get_rapids_dataset_root_dir(); + rel_file = rapidsDatasetRootDir + "/" + file; + auto data_model = parse_mps(rel_file, false); + + EXPECT_NEAR(1.2, data_model.get_constraint_lower_bounds()[0], tolerance); // ROW1 lower bound + EXPECT_NEAR(5.4, data_model.get_constraint_upper_bounds()[0], tolerance); // ROW1 upper bound + EXPECT_NEAR(1.5, data_model.get_constraint_lower_bounds()[1], tolerance); // ROW2 lower bound + EXPECT_NEAR(4.9, data_model.get_constraint_upper_bounds()[1], tolerance); // ROW2 upper bound + EXPECT_NEAR( + 7.9, data_model.get_constraint_lower_bounds()[2], tolerance); // ROW3, equal constraint + EXPECT_NEAR( + 9.5, data_model.get_constraint_upper_bounds()[2], tolerance); // ROW3, equal constraint + EXPECT_NEAR( + 3.5, data_model.get_constraint_lower_bounds()[3], tolerance); // ROW4, equal constraint + EXPECT_NEAR( + 6.9, data_model.get_constraint_upper_bounds()[3], tolerance); // ROW4, equal constraint + EXPECT_NEAR(3.9, + data_model.get_constraint_lower_bounds()[4], + tolerance); // ROW5, lower turned into equal constraint + EXPECT_NEAR(3.9, + data_model.get_constraint_upper_bounds()[4], + tolerance); // ROW5, lower turned into equal constraint + EXPECT_NEAR(4.9, + data_model.get_constraint_lower_bounds()[5], + tolerance); // ROW6, greater turned into equal constraint + EXPECT_NEAR(4.9, + data_model.get_constraint_upper_bounds()[5], + tolerance); // ROW6, greater turned into equal constraint +} + +TEST(mps_name, two_objectives) +{ + std::string file = "linear_programming/good-mps-fixed-two-objectives.mps"; + auto mps = read_from_mps(file, false); + + // Objective name should be first one found and not trigger an error + EXPECT_EQ(mps.objective_name, "COST"); +} + +TEST(mps_objname, two_objectives) +{ + std::string file = "linear_programming/good-mps-fixed-two-objectives-objname.mps"; + auto mps = read_from_mps(file, false); + + // Objective name is the second one found since it's specified as objname + EXPECT_EQ(mps.objective_name, "COST6679327"); +} + +TEST(mps_objname, two_objectives_next_line) +{ + std::string file = "linear_programming/good-mps-fixed-two-objectives-objname-next-line.mps"; + auto mps = read_from_mps(file, false); + + // Objective name is the second one found since it's specified as objname + EXPECT_EQ(mps.objective_name, "COST6679327"); +} + +TEST(mps_objname, bad_after) +{ + std::string file = "linear_programming/bad-mps-fixed-objname-after-rows.mps"; + ASSERT_THROW(read_from_mps(file, false), std::logic_error); +} + +TEST(mps_objname, bad_no_fixed) +{ + std::string file = "linear_programming/bad-mps-fixed-objname-after-rows.mps"; + ASSERT_THROW(read_from_mps(file, true), std::logic_error); +} + +TEST(mps_ranges, bad_name) +{ + ASSERT_THROW(read_from_mps("linear_programming/bad-mps-fixed-ranges-name.mps", false), + std::logic_error); +} + +TEST(mps_ranges, bad_value) +{ + ASSERT_THROW(read_from_mps("linear_programming/bad-mps-fixed-ranges-value.mps", false), + std::logic_error); +} + +TEST(mps_bounds, unsupported_or_invalid_mps_types) +{ + std::stringstream ss; + static constexpr int NumMpsFiles = 2; + for (int i = 1; i <= NumMpsFiles; ++i) { + ss << "linear_programming/bad-mps-bound-" << i << ".mps"; + ASSERT_THROW(read_from_mps(ss.str(), false), std::logic_error); + ss.str(std::string{}); + ss.clear(); + }; +} + +TEST_F(mip_with_bounds_test, mps) +{ + check_model(parse_mps_file("mixed_integer_programming/good-mip-mps-1.mps", false)); + auto mps = read_from_mps("mixed_integer_programming/good-mip-mps-1.mps", false); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); +} + +TEST_F(mip_with_bounds_test, lp) +{ + check_model(parse_lp_file("mixed_integer_programming/good-mip-mps-1.lp")); +} + +TEST(mps_parser, good_mps_file_mip_no_marker) +{ + auto mps = read_from_mps("mixed_integer_programming/good-mip-mps-1-no-mark.mps", false); + + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("ROW1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VAR1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(2), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + EXPECT_EQ(int(1), mps.A_indices[1][1]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(8000., mps.A_values[0][0]); + EXPECT_EQ(4000., mps.A_values[0][1]); + ASSERT_EQ(int(2), mps.A_values[1].size()); + EXPECT_EQ(15., mps.A_values[1][0]); + EXPECT_EQ(30., mps.A_values[1][1]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(40000., mps.b_values[0]); + EXPECT_EQ(200., mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(100., mps.c_values[0]); + EXPECT_EQ(150., mps.c_values[1]); + ASSERT_EQ(int(2), mps.var_types.size()); + EXPECT_EQ('I', mps.var_types[0]); + EXPECT_EQ('I', mps.var_types[1]); + ASSERT_EQ(int(2), mps.variable_lower_bounds.size()); + EXPECT_EQ(0., mps.variable_lower_bounds[0]); + EXPECT_EQ(0., mps.variable_lower_bounds[1]); + ASSERT_EQ(int(2), mps.variable_upper_bounds.size()); + EXPECT_EQ(10., mps.variable_upper_bounds[0]); + EXPECT_EQ(10., mps.variable_upper_bounds[1]); +} + +TEST_F(mip_no_bounds_test, mps) +{ + check_model(parse_mps_file("mixed_integer_programming/good-mip-mps-no-bounds.mps", false)); +} + +TEST_F(mip_no_bounds_test, lp) +{ + check_model(parse_lp_file("mixed_integer_programming/good-mip-mps-no-bounds.lp")); +} + +TEST_F(mip_partial_bounds_test, mps) +{ + check_model(parse_mps_file("mixed_integer_programming/good-mip-mps-partial-bounds.mps", false)); +} + +TEST_F(mip_partial_bounds_test, lp) +{ + check_model(parse_lp_file("mixed_integer_programming/good-mip-mps-partial-bounds.lp")); +} + +#ifdef MPS_PARSER_WITH_BZIP2 +TEST(mps_parser, good_mps_file_bzip2_compressed) +{ + auto mps = read_from_mps("linear_programming/good-mps-1.mps.bz2"); + EXPECT_EQ("good-1", mps.problem_name); + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("ROW1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VAR1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(2), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + EXPECT_EQ(int(1), mps.A_indices[1][1]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(3., mps.A_values[0][0]); + EXPECT_EQ(4., mps.A_values[0][1]); + ASSERT_EQ(int(2), mps.A_values[1].size()); + EXPECT_EQ(2.7, mps.A_values[1][0]); + EXPECT_EQ(10.1, mps.A_values[1][1]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(5.4, mps.b_values[0]); + EXPECT_EQ(4.9, mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(0.2, mps.c_values[0]); + EXPECT_EQ(0.1, mps.c_values[1]); +} +#endif // MPS_PARSER_WITH_BZIP2 + +#ifdef MPS_PARSER_WITH_ZLIB +TEST(mps_parser, good_mps_file_zlib_compressed) +{ + auto mps = read_from_mps("linear_programming/good-mps-1.mps.gz"); + EXPECT_EQ("good-1", mps.problem_name); + ASSERT_EQ(int(2), mps.row_names.size()); + EXPECT_EQ("ROW1", mps.row_names[0]); + EXPECT_EQ("ROW2", mps.row_names[1]); + ASSERT_EQ(int(2), mps.row_types.size()); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[0]); + EXPECT_EQ(LesserThanOrEqual, mps.row_types[1]); + EXPECT_EQ("COST", mps.objective_name); + ASSERT_EQ(int(2), mps.var_names.size()); + EXPECT_EQ("VAR1", mps.var_names[0]); + EXPECT_EQ("VAR2", mps.var_names[1]); + ASSERT_EQ(int(2), mps.A_indices.size()); + ASSERT_EQ(int(2), mps.A_indices[0].size()); + EXPECT_EQ(int(0), mps.A_indices[0][0]); + EXPECT_EQ(int(1), mps.A_indices[0][1]); + ASSERT_EQ(int(2), mps.A_indices[1].size()); + EXPECT_EQ(int(0), mps.A_indices[1][0]); + EXPECT_EQ(int(1), mps.A_indices[1][1]); + ASSERT_EQ(int(2), mps.A_values.size()); + ASSERT_EQ(int(2), mps.A_values[0].size()); + EXPECT_EQ(3., mps.A_values[0][0]); + EXPECT_EQ(4., mps.A_values[0][1]); + ASSERT_EQ(int(2), mps.A_values[1].size()); + EXPECT_EQ(2.7, mps.A_values[1][0]); + EXPECT_EQ(10.1, mps.A_values[1][1]); + ASSERT_EQ(int(2), mps.b_values.size()); + EXPECT_EQ(5.4, mps.b_values[0]); + EXPECT_EQ(4.9, mps.b_values[1]); + ASSERT_EQ(int(2), mps.c_values.size()); + EXPECT_EQ(0.2, mps.c_values[0]); + EXPECT_EQ(0.1, mps.c_values[1]); +} +#endif // MPS_PARSER_WITH_ZLIB + +// ================================================================================================ +// QPS (Quadratic Programming) Support Tests +// ================================================================================================ + +// QPS-specific tests for quadratic programming support +TEST(qps_parser, quadratic_objective_basic) +{ + // Create a simple QPS test to verify quadratic objective parsing + // This would require actual QPS test files - for now, test the API + mps_data_model_t model; + + // Test setting quadratic objective matrix + std::vector Q_values = {2.0, 1.0, 1.0, 2.0}; // 2x2 matrix + std::vector Q_indices = {0, 1, 0, 1}; + std::vector Q_offsets = {0, 2, 4}; // CSR offsets + + model.set_quadratic_objective_matrix(Q_values, Q_indices, Q_offsets); + + // Verify the data was stored correctly + EXPECT_TRUE(model.has_quadratic_objective()); + EXPECT_EQ(4, model.get_quadratic_objective_values().size()); + EXPECT_EQ(2.0, model.get_quadratic_objective_values()[0]); + EXPECT_EQ(1.0, model.get_quadratic_objective_values()[1]); +} + +// Test actual QPS files from the dataset +TEST(qps_parser, test_qps_files) +{ + // Test QP_Test_1.qps if it exists + if (file_exists("quadratic_programming/QP_Test_1.qps")) { + auto parsed_data = parse_mps( + cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_1.qps", false); + + EXPECT_EQ("QP_Test_1", parsed_data.get_problem_name()); + EXPECT_EQ(2, parsed_data.get_n_variables()); // C------1 and C------2 + EXPECT_EQ(1, parsed_data.get_n_constraints()); // R------1 + EXPECT_TRUE(parsed_data.has_quadratic_objective()); + + // Check variable bounds + const auto& lower_bounds = parsed_data.get_variable_lower_bounds(); + const auto& upper_bounds = parsed_data.get_variable_upper_bounds(); + + EXPECT_NEAR(2.0, lower_bounds[0], tolerance); // C------1 lower bound + EXPECT_NEAR(50.0, upper_bounds[0], tolerance); // C------1 upper bound + EXPECT_NEAR(-50.0, lower_bounds[1], tolerance); // C------2 lower bound + EXPECT_NEAR(50.0, upper_bounds[1], tolerance); // C------2 upper bound + } + + // Test QP_Test_2.qps if it exists + if (file_exists("quadratic_programming/QP_Test_2.qps")) { + auto parsed_data = parse_mps( + cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_2.qps", false); + + EXPECT_EQ("QP_Test_2", parsed_data.get_problem_name()); + EXPECT_EQ(3, parsed_data.get_n_variables()); // C------1, C------2, C------3 + EXPECT_EQ(1, parsed_data.get_n_constraints()); // R------1 + EXPECT_TRUE(parsed_data.has_quadratic_objective()); + + // Check that quadratic objective matrix has values + const auto& Q_values = parsed_data.get_quadratic_objective_values(); + EXPECT_GT(Q_values.size(), 0) << "Quadratic objective should have non-zero elements"; + } +} + +// ================================================================================================ +// MPS Round-Trip Tests (Read -> Write -> Read -> Compare) +// ================================================================================================ + +// Helper function to compare two data models +template +void compare_data_models(const mps_data_model_t& original, + const mps_data_model_t& reloaded, + f_t tol = 1e-9) +{ + // Compare basic dimensions + EXPECT_EQ(original.get_n_variables(), reloaded.get_n_variables()); + EXPECT_EQ(original.get_n_constraints(), reloaded.get_n_constraints()); + + // Compare objective coefficients + auto orig_c = original.get_objective_coefficients(); + auto reload_c = reloaded.get_objective_coefficients(); + ASSERT_EQ(orig_c.size(), reload_c.size()); + for (size_t i = 0; i < orig_c.size(); ++i) { + EXPECT_NEAR(orig_c[i], reload_c[i], tol) << "Objective coefficient mismatch at index " << i; + } + + // Compare constraint matrix values + auto orig_A = original.get_constraint_matrix_values(); + auto reload_A = reloaded.get_constraint_matrix_values(); + ASSERT_EQ(orig_A.size(), reload_A.size()); + for (size_t i = 0; i < orig_A.size(); ++i) { + EXPECT_NEAR(orig_A[i], reload_A[i], tol) << "Constraint matrix value mismatch at index " << i; + } + + // Compare constraint matrix indices + auto orig_A_idx = original.get_constraint_matrix_indices(); + auto reload_A_idx = reloaded.get_constraint_matrix_indices(); + ASSERT_EQ(orig_A_idx.size(), reload_A_idx.size()); + for (size_t i = 0; i < orig_A_idx.size(); ++i) { + EXPECT_EQ(orig_A_idx[i], reload_A_idx[i]) << "Constraint matrix index mismatch at index " << i; + } + + // Compare constraint matrix offsets + auto orig_A_off = original.get_constraint_matrix_offsets(); + auto reload_A_off = reloaded.get_constraint_matrix_offsets(); + ASSERT_EQ(orig_A_off.size(), reload_A_off.size()); + for (size_t i = 0; i < orig_A_off.size(); ++i) { + EXPECT_EQ(orig_A_off[i], reload_A_off[i]) << "Constraint matrix offset mismatch at index " << i; + } + + // Compare variable bounds + auto orig_lb = original.get_variable_lower_bounds(); + auto reload_lb = reloaded.get_variable_lower_bounds(); + ASSERT_EQ(orig_lb.size(), reload_lb.size()); + for (size_t i = 0; i < orig_lb.size(); ++i) { + if (std::isinf(orig_lb[i]) && std::isinf(reload_lb[i])) { + EXPECT_EQ(std::signbit(orig_lb[i]), std::signbit(reload_lb[i])) + << "Variable lower bound infinity sign mismatch at index " << i; + } else { + EXPECT_NEAR(orig_lb[i], reload_lb[i], tol) << "Variable lower bound mismatch at index " << i; + } + } + + auto orig_ub = original.get_variable_upper_bounds(); + auto reload_ub = reloaded.get_variable_upper_bounds(); + ASSERT_EQ(orig_ub.size(), reload_ub.size()); + for (size_t i = 0; i < orig_ub.size(); ++i) { + if (std::isinf(orig_ub[i]) && std::isinf(reload_ub[i])) { + EXPECT_EQ(std::signbit(orig_ub[i]), std::signbit(reload_ub[i])) + << "Variable upper bound infinity sign mismatch at index " << i; + } else { + EXPECT_NEAR(orig_ub[i], reload_ub[i], tol) << "Variable upper bound mismatch at index " << i; + } + } + + // Compare constraint bounds + auto orig_cl = original.get_constraint_lower_bounds(); + auto reload_cl = reloaded.get_constraint_lower_bounds(); + ASSERT_EQ(orig_cl.size(), reload_cl.size()); + for (size_t i = 0; i < orig_cl.size(); ++i) { + if (std::isinf(orig_cl[i]) && std::isinf(reload_cl[i])) { + EXPECT_EQ(std::signbit(orig_cl[i]), std::signbit(reload_cl[i])) + << "Constraint lower bound infinity sign mismatch at index " << i; + } else { + EXPECT_NEAR(orig_cl[i], reload_cl[i], tol) + << "Constraint lower bound mismatch at index " << i; + } + } + + auto orig_cu = original.get_constraint_upper_bounds(); + auto reload_cu = reloaded.get_constraint_upper_bounds(); + ASSERT_EQ(orig_cu.size(), reload_cu.size()); + for (size_t i = 0; i < orig_cu.size(); ++i) { + if (std::isinf(orig_cu[i]) && std::isinf(reload_cu[i])) { + EXPECT_EQ(std::signbit(orig_cu[i]), std::signbit(reload_cu[i])) + << "Constraint upper bound infinity sign mismatch at index " << i; + } else { + EXPECT_NEAR(orig_cu[i], reload_cu[i], tol) + << "Constraint upper bound mismatch at index " << i; + } + } + + // Compare quadratic objective if present + EXPECT_EQ(original.has_quadratic_objective(), reloaded.has_quadratic_objective()); + if (original.has_quadratic_objective() && reloaded.has_quadratic_objective()) { + auto orig_Q = original.get_quadratic_objective_values(); + auto orig_Q_idx = original.get_quadratic_objective_indices(); + auto orig_Q_off = original.get_quadratic_objective_offsets(); + auto reload_Q = reloaded.get_quadratic_objective_values(); + auto reload_Q_idx = reloaded.get_quadratic_objective_indices(); + auto reload_Q_off = reloaded.get_quadratic_objective_offsets(); + + // Compare Q matrix structure and values + ASSERT_EQ(orig_Q.size(), reload_Q.size()) << "Q values size mismatch"; + ASSERT_EQ(orig_Q_idx.size(), reload_Q_idx.size()) << "Q indices size mismatch"; + ASSERT_EQ(orig_Q_off.size(), reload_Q_off.size()) << "Q offsets size mismatch"; + + for (size_t i = 0; i < orig_Q.size(); ++i) { + EXPECT_NEAR(orig_Q[i], reload_Q[i], tol) << "Q value mismatch at index " << i; + } + for (size_t i = 0; i < orig_Q_idx.size(); ++i) { + EXPECT_EQ(orig_Q_idx[i], reload_Q_idx[i]) << "Q index mismatch at index " << i; + } + for (size_t i = 0; i < orig_Q_off.size(); ++i) { + EXPECT_EQ(orig_Q_off[i], reload_Q_off[i]) << "Q offset mismatch at index " << i; + } + } +} + +TEST(mps_roundtrip, linear_programming_basic) +{ + std::string input_file = + cuopt::test::get_rapids_dataset_root_dir() + "/linear_programming/good-mps-1.mps"; + std::string temp_file = "/tmp/mps_roundtrip_lp_test.mps"; + + // Read original + auto original = parse_mps(input_file, true); + + // Write to temp file + mps_writer_t writer(original); + writer.write(temp_file); + + // Read back + auto reloaded = parse_mps(temp_file, false); + + // Compare + compare_data_models(original, reloaded); + + // Cleanup + std::filesystem::remove(temp_file); +} + +TEST(mps_roundtrip, linear_programming_with_bounds) +{ + if (!file_exists("linear_programming/lp_model_with_var_bounds.mps")) { + GTEST_SKIP() << "Test file not found"; + } + + std::string input_file = + cuopt::test::get_rapids_dataset_root_dir() + "/linear_programming/lp_model_with_var_bounds.mps"; + std::string temp_file = "/tmp/mps_roundtrip_lp_bounds_test.mps"; + + // Read original + auto original = parse_mps(input_file, false); + + // Write to temp file + mps_writer_t writer(original); + writer.write(temp_file); + + // Read back + auto reloaded = parse_mps(temp_file, false); + + // Compare + compare_data_models(original, reloaded); + + // Cleanup + std::filesystem::remove(temp_file); +} + +TEST(mps_roundtrip, quadratic_programming_qp_test_1) +{ + if (!file_exists("quadratic_programming/QP_Test_1.qps")) { + GTEST_SKIP() << "Test file not found"; + } + + std::string input_file = + cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_1.qps"; + std::string temp_file = "/tmp/mps_roundtrip_qp_test_1.mps"; + + // Read original + auto original = parse_mps(input_file, false); + ASSERT_TRUE(original.has_quadratic_objective()) << "Original should have quadratic objective"; + + // Write to temp file + mps_writer_t writer(original); + writer.write(temp_file); + + // Read back + auto reloaded = parse_mps(temp_file, false); + ASSERT_TRUE(reloaded.has_quadratic_objective()) << "Reloaded should have quadratic objective"; + + // Compare + compare_data_models(original, reloaded); + + // Cleanup + std::filesystem::remove(temp_file); +} + +TEST(mps_roundtrip, quadratic_programming_qp_test_2) +{ + if (!file_exists("quadratic_programming/QP_Test_2.qps")) { + GTEST_SKIP() << "Test file not found"; + } + + std::string input_file = + cuopt::test::get_rapids_dataset_root_dir() + "/quadratic_programming/QP_Test_2.qps"; + std::string temp_file = "/tmp/mps_roundtrip_qp_test_2.mps"; + + // Read original + auto original = parse_mps(input_file, false); + ASSERT_TRUE(original.has_quadratic_objective()) << "Original should have quadratic objective"; + + // Write to temp file + mps_writer_t writer(original); + writer.write(temp_file); + + // Read back + auto reloaded = parse_mps(temp_file, false); + ASSERT_TRUE(reloaded.has_quadratic_objective()) << "Reloaded should have quadratic objective"; + + // Compare + compare_data_models(original, reloaded); + + // Cleanup + std::filesystem::remove(temp_file); +} + +// ================================================================================================ +// LP -> MPS Round-Trip Tests (Read LP -> Write MPS -> Read MPS -> Compare) +// ================================================================================================ +// Parses an LP file, writes the resulting data model out as MPS, reads it +// back, and checks that the reloaded data model matches the one produced by +// the LP parser. Exercises the LP reader + the writer + the MPS reader end +// to end, without trusting any direct LP<->MPS comparison. + +TEST_F(good_mps_1_test, lp_roundtrip) +{ + std::string temp_file = "/tmp/lp_roundtrip_lp_basic.mps"; + + auto original = parse_lp_file("linear_programming/good-mps-1.lp"); + + mps_writer_t writer(original); + writer.write(temp_file); + + auto reloaded = parse_mps(temp_file, false); + + compare_data_models(original, reloaded); + + std::filesystem::remove(temp_file); +} + +TEST_F(up_low_bounds_test, lp_roundtrip) +{ + std::string temp_file = "/tmp/lp_roundtrip_lp_bounds.mps"; + + auto original = parse_lp_file("linear_programming/lp_model_with_var_bounds.lp"); + + mps_writer_t writer(original); + writer.write(temp_file); + + auto reloaded = parse_mps(temp_file, false); + + compare_data_models(original, reloaded); + + std::filesystem::remove(temp_file); +} + +TEST_F(mip_with_bounds_test, lp_roundtrip) +{ + std::string temp_file = "/tmp/lp_roundtrip_mip_basic.mps"; + + auto original = parse_lp_file("mixed_integer_programming/good-mip-mps-1.lp"); + + mps_writer_t writer(original); + writer.write(temp_file); + + auto reloaded = parse_mps(temp_file, false); + + compare_data_models(original, reloaded); + + std::filesystem::remove(temp_file); +} + +// ================================================================================================ +// LP syntax / feature / error-path tests (parse_lp on inline LP content) +// ================================================================================================ + +TEST(lp_parser, trivial) +{ + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + lb_constr: x >= 2.5 +Bounds + x <= 10 +End +)LP"); + + EXPECT_FALSE(m.get_sense()); // minimize + ASSERT_EQ(m.get_variable_names().size(), 1u); + int x = find_var(m, "x"); + ASSERT_GE(x, 0); + EXPECT_EQ(m.get_variable_types()[x], 'C'); + EXPECT_NEAR(m.get_variable_lower_bounds()[x], 0.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[x], 10.0, tolerance); + EXPECT_NEAR(m.get_objective_coefficients()[x], 1.0, tolerance); + + ASSERT_EQ(m.get_row_names().size(), 1u); + int r = find_row(m, "lb_constr"); + ASSERT_GE(r, 0); + // 'G' relation ⇒ finite lower bound, +inf upper bound. + EXPECT_NEAR(m.get_constraint_lower_bounds()[r], 2.5, tolerance); + EXPECT_TRUE(std::isinf(m.get_constraint_upper_bounds()[r])); + EXPECT_NEAR(a_entry(m, r, x), 1.0, tolerance); +} + +TEST(lp_parser, basic_lp_with_float_coefficients) +{ + auto m = parse_lp_string(R"LP( +Minimize + x1 + x2 +Subject To + c1: 2.5 x1 + x2 <= 10 + c2: x1 + 1.5 x2 <= 8 + c3: x1 + x2 <= 6 +End +)LP"); + + EXPECT_EQ(m.get_variable_names().size(), 2u); + int x1 = find_var(m, "x1"); + int x2 = find_var(m, "x2"); + ASSERT_GE(x1, 0); + ASSERT_GE(x2, 0); + // Default bounds for continuous variables. + EXPECT_NEAR(m.get_variable_lower_bounds()[x1], 0.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[x1])); + + ASSERT_EQ(m.get_row_names().size(), 3u); + int c1 = find_row(m, "c1"); + int c2 = find_row(m, "c2"); + ASSERT_GE(c1, 0); + ASSERT_GE(c2, 0); + EXPECT_NEAR(a_entry(m, c1, x1), 2.5, tolerance); + EXPECT_NEAR(a_entry(m, c1, x2), 1.0, tolerance); + EXPECT_NEAR(a_entry(m, c2, x2), 1.5, tolerance); + EXPECT_NEAR(m.get_constraint_upper_bounds()[c1], 10.0, tolerance); +} + +TEST(lp_parser, maximize_flips_sense) +{ + auto m = parse_lp_string(R"LP( +Maximize + 3 x + 2 y +Subject To + c1: x + y <= 6 + c2: 2 x + y <= 8 +End +)LP"); + + EXPECT_TRUE(m.get_sense()); + int x = find_var(m, "x"); + int y = find_var(m, "y"); + EXPECT_NEAR(m.get_objective_coefficients()[x], 3.0, tolerance); + EXPECT_NEAR(m.get_objective_coefficients()[y], 2.0, tolerance); +} + +TEST(lp_parser, equality_constraints) +{ + auto m = parse_lp_string(R"LP( +Minimize + c1 + 2 c2 + 3 c3 + 4 c4 +Subject To + s1: c1 + c2 = 10 + s2: c3 + c4 = 12 + d1: c1 + c3 = 9 + d2: c2 + c4 = 13 +End +)LP"); + + ASSERT_EQ(m.get_row_names().size(), 4u); + // All four are equality constraints ⇒ lb == ub for every row. + const auto& clb = m.get_constraint_lower_bounds(); + const auto& cub = m.get_constraint_upper_bounds(); + for (size_t i = 0; i < clb.size(); ++i) { + EXPECT_NEAR(clb[i], cub[i], tolerance); + } + int s1 = find_row(m, "s1"); + EXPECT_NEAR(m.get_constraint_lower_bounds()[s1], 10.0, tolerance); + EXPECT_NEAR(m.get_constraint_upper_bounds()[s1], 10.0, tolerance); +} + +TEST(lp_parser, mixed_constraint_relations) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + 2 y + 3 z +Subject To + eq1: x + y + z = 10 + geq1: x + 2 y >= 6 + leq1: y + z <= 8 +End +)LP"); + + int eq = find_row(m, "eq1"); + int geq = find_row(m, "geq1"); + int leq = find_row(m, "leq1"); + // Relation is recovered from the constraint lower/upper bounds: + // 'E' ⇒ lb == ub + // 'G' ⇒ ub = +inf + // 'L' ⇒ lb = -inf + EXPECT_NEAR(m.get_constraint_lower_bounds()[eq], m.get_constraint_upper_bounds()[eq], tolerance); + EXPECT_NEAR(m.get_constraint_lower_bounds()[geq], 6.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_constraint_upper_bounds()[geq])); + EXPECT_NEAR(m.get_constraint_upper_bounds()[leq], 8.0, tolerance); + EXPECT_TRUE(std::isinf(-m.get_constraint_lower_bounds()[leq])); +} + +TEST(lp_parser, free_and_negative_lower_bound_variables) +{ + auto m = parse_lp_string(R"LP( +Minimize + xfree + xneg_lb + xstd +Subject To + sum_lb: xfree + xneg_lb + xstd >= 1 + diff_ub: xfree - xneg_lb <= 3 + xst_cap: xstd <= 5 +Bounds + xfree free + -3 <= xneg_lb <= 10 +End +)LP"); + + int xf = find_var(m, "xfree"); + int xn = find_var(m, "xneg_lb"); + int xs = find_var(m, "xstd"); + EXPECT_TRUE(std::isinf(-m.get_variable_lower_bounds()[xf])); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[xf])); + EXPECT_NEAR(m.get_variable_lower_bounds()[xn], -3.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[xn], 10.0, tolerance); + EXPECT_NEAR(m.get_variable_lower_bounds()[xs], 0.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[xs])); + + // - xneg_lb → coefficient -1 in the diff_ub row + int dr = find_row(m, "diff_ub"); + EXPECT_NEAR(a_entry(m, dr, xn), -1.0, tolerance); +} + +TEST(lp_parser, bounds_variety) +{ + auto m = parse_lp_string(R"LP( +Minimize + xfixed + xub_only + xlb_pos +Subject To + c1: xfixed + xub_only + xlb_pos >= 1 +Bounds + xfixed = 3 + xub_only <= 7.5 + xlb_pos >= 2 +End +)LP"); + + int xfixed = find_var(m, "xfixed"); + int xub = find_var(m, "xub_only"); + int xlb = find_var(m, "xlb_pos"); + EXPECT_NEAR(m.get_variable_lower_bounds()[xfixed], 3.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[xfixed], 3.0, tolerance); + EXPECT_NEAR(m.get_variable_lower_bounds()[xub], 0.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[xub], 7.5, tolerance); + EXPECT_NEAR(m.get_variable_lower_bounds()[xlb], 2.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[xlb])); +} + +TEST(lp_parser, general_integers) +{ + auto m = parse_lp_string(R"LP( +Maximize + 3 x + 5 y +Subject To + c1: x + 2 y <= 12 + c2: 2 x + y <= 10 +Generals + x y +End +)LP"); + + int x = find_var(m, "x"); + int y = find_var(m, "y"); + EXPECT_EQ(m.get_variable_types()[x], 'I'); + EXPECT_EQ(m.get_variable_types()[y], 'I'); + // Generals alone does NOT force [0,1]; default bounds remain [0, +inf). + EXPECT_NEAR(m.get_variable_lower_bounds()[x], 0.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[x])); +} + +TEST(lp_parser, binaries_set_zero_one_bounds) +{ + auto m = parse_lp_string(R"LP( +Maximize + 3 x1 + 5 x2 + 4 x3 + 2 x4 +Subject To + knapsack: 2 x1 + 3 x2 + x3 + x4 <= 5 +Binaries + x1 x2 x3 x4 +End +)LP"); + + for (const std::string& n : {"x1", "x2", "x3", "x4"}) { + int v = find_var(m, n); + EXPECT_EQ(m.get_variable_types()[v], 'I'); + EXPECT_NEAR(m.get_variable_lower_bounds()[v], 0.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[v], 1.0, tolerance); + } +} + +TEST(lp_parser, mixed_continuous_integer_binary) +{ + auto m = parse_lp_string(R"LP( +Maximize + 3 xc + 4 xi + 7 xb +Subject To + c1: xc + xi + xb <= 10 +Generals + xi +Binaries + xb +End +)LP"); + + int xc = find_var(m, "xc"); + int xi = find_var(m, "xi"); + int xb = find_var(m, "xb"); + EXPECT_EQ(m.get_variable_types()[xc], 'C'); + EXPECT_EQ(m.get_variable_types()[xi], 'I'); + EXPECT_EQ(m.get_variable_types()[xb], 'I'); + EXPECT_NEAR(m.get_variable_upper_bounds()[xb], 1.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[xi])); +} + +TEST(lp_parser, quadratic_diagonal_only) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + y + [ 2 x ^2 + 4 y ^2 ] / 2 +Subject To + c1: x + y >= 1 +Bounds + x free + y free +End +)LP"); + + ASSERT_TRUE(m.has_quadratic_objective()); + int x = find_var(m, "x"); + int y = find_var(m, "y"); + // LP [2 x^2]/2 = x^2 ⇒ Q[x,x] should be 1 in cuOpt's x^T Q x form. + EXPECT_NEAR(q_entry(m, x, x), 1.0, tolerance); + // LP [4 y^2]/2 = 2 y^2 ⇒ Q[y,y] = 2. + EXPECT_NEAR(q_entry(m, y, y), 2.0, tolerance); + EXPECT_NEAR(q_entry(m, x, y), 0.0, tolerance); + // Linear part is preserved. + EXPECT_NEAR(m.get_objective_coefficients()[x], 1.0, tolerance); + EXPECT_NEAR(m.get_objective_coefficients()[y], 1.0, tolerance); +} + +TEST(lp_parser, quadratic_with_cross_terms) +{ + auto m = parse_lp_string(R"LP( +Minimize + - 3 x - 4 y - 2 z + [ 2 x ^2 + 2 x * y + 2 y ^2 + 2 y * z + 2 z ^2 ] / 2 +Subject To + c1: x + y + z <= 10 + c2: x + y >= 1 +End +)LP"); + + ASSERT_TRUE(m.has_quadratic_objective()); + int x = find_var(m, "x"); + int y = find_var(m, "y"); + int z = find_var(m, "z"); + // Diagonal 2 x^2 / 2 = x^2 ⇒ Q[x,x] = 1, similarly for y, z. + EXPECT_NEAR(q_entry(m, x, x), 1.0, tolerance); + EXPECT_NEAR(q_entry(m, y, y), 1.0, tolerance); + EXPECT_NEAR(q_entry(m, z, z), 1.0, tolerance); + // Cross 2 x*y / 2 = x*y ⇒ full matrix Q[x,y] = Q[y,x] = 0.5 each + // (so that x^T Q x sums to x*y). + EXPECT_NEAR(q_entry(m, x, y), 0.5, tolerance); + EXPECT_NEAR(q_entry(m, y, x), 0.5, tolerance); + EXPECT_NEAR(q_entry(m, y, z), 0.5, tolerance); + EXPECT_NEAR(q_entry(m, z, y), 0.5, tolerance); + // x and z have no cross term. + EXPECT_NEAR(q_entry(m, x, z), 0.0, tolerance); + + EXPECT_NEAR(m.get_objective_coefficients()[x], -3.0, tolerance); + EXPECT_NEAR(m.get_objective_coefficients()[y], -4.0, tolerance); + EXPECT_NEAR(m.get_objective_coefficients()[z], -2.0, tolerance); +} + +TEST(lp_parser, miqp_integer_with_quadratic_objective) +{ + auto m = parse_lp_string(R"LP( +Minimize + - 4 xi - 2 xc + [ 2 xi ^2 + 2 xc ^2 ] / 2 +Subject To + c1: xi + xc <= 5 +Bounds + xi <= 4 +Generals + xi +End +)LP"); + + int xi = find_var(m, "xi"); + int xc = find_var(m, "xc"); + EXPECT_EQ(m.get_variable_types()[xi], 'I'); + EXPECT_EQ(m.get_variable_types()[xc], 'C'); + EXPECT_NEAR(m.get_variable_upper_bounds()[xi], 4.0, tolerance); + EXPECT_NEAR(q_entry(m, xi, xi), 1.0, tolerance); + EXPECT_NEAR(q_entry(m, xc, xc), 1.0, tolerance); +} + +TEST(lp_parser, infeasible_model_parses_faithfully) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + high: x + y >= 15 + low: x + y <= 8 +Bounds + x <= 5 + y <= 5 +End +)LP"); + + EXPECT_EQ(m.get_row_names().size(), 2u); + int high = find_row(m, "high"); + int low = find_row(m, "low"); + EXPECT_NEAR(m.get_constraint_lower_bounds()[high], 15.0, tolerance); + EXPECT_NEAR(m.get_constraint_upper_bounds()[low], 8.0, tolerance); +} + +TEST(lp_parser, unbounded_model_parses) +{ + auto m = parse_lp_string(R"LP( +Maximize + x + y +Subject To + c1: x - y <= 5 +End +)LP"); + + int x = find_var(m, "x"); + EXPECT_NEAR(m.get_variable_lower_bounds()[x], 0.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[x])); + EXPECT_TRUE(m.get_sense()); +} + +TEST(lp_parser, missing_objective_throws) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Subject To + c1: x + y <= 5 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, unsupported_sos_section_throws) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 1 +SOS + s1: S1 :: x : 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, semi_continuous_basic) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + c1: x + y >= 1 +Bounds + 2 <= x <= 10 + y <= 5 +Semi-Continuous + x +End +)LP"); + ASSERT_EQ(m.get_variable_names().size(), 2u); + int xi = find_var(m, "x"); + int yi = find_var(m, "y"); + ASSERT_GE(xi, 0); + ASSERT_GE(yi, 0); + EXPECT_EQ(m.get_variable_types()[xi], 'S'); + EXPECT_EQ(m.get_variable_types()[yi], 'C'); + EXPECT_NEAR(m.get_variable_lower_bounds()[xi], 2.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[xi], 10.0, tolerance); +} + +TEST(lp_parser, semi_continuous_default_lower_is_zero) +{ + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 0 +Bounds + x <= 3 +Semi-Continuous + x +End +)LP"); + int xi = find_var(m, "x"); + ASSERT_GE(xi, 0); + EXPECT_EQ(m.get_variable_types()[xi], 'S'); + // No explicit lower in Bounds ⇒ default 0. + EXPECT_NEAR(m.get_variable_lower_bounds()[xi], 0.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[xi], 3.0, tolerance); +} + +TEST(lp_parser, semi_continuous_missing_upper_throws) +{ + // No upper bound specified ⇒ infinity ⇒ semantics degenerate, reject. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 0 +Semi-Continuous + x +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, semi_continuous_and_generals_conflict_throws) +{ + // Variable appearing in both Semi-Continuous and Generals is ambiguous + // (integer vs. continuous-or-zero) ⇒ reject. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 0 +Bounds + x <= 5 +Generals + x +Semi-Continuous + x +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, semi_continuous_and_binaries_conflict_throws) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 0 +Bounds + x <= 5 +Binaries + x +Semi-Continuous + x +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, semi_continuous_before_generals_conflict_throws) +{ + // Conflict must also be detected when Semi-Continuous is declared first. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 0 +Bounds + x <= 5 +Semi-Continuous + x +Generals + x +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, unsupported_pwlobj_section_throws) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 1 +PWLObj + x: 0 0 1 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, unsupported_lazy_constraints_section_throws) +{ + // Lazy constraints and user cuts are scope-limited out: LP/MIP/QP only. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 1 +Lazy Constraints + lc: x <= 10 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, unsupported_user_cuts_section_throws) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 1 +User Cuts + uc: x <= 10 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, unknown_file_throws) +{ + auto call = [] { return parse_lp("/definitely/does/not/exist.lp"); }; + EXPECT_THROW(call(), std::logic_error); +} + +TEST(lp_parser, case_insensitive_section_keywords) +{ + auto m = parse_lp_string(R"LP( +MINIMIZE + x +SUBJECT TO + c1: x >= 1 +BOUNDS + x <= 5 +END +)LP"); + int x = find_var(m, "x"); + EXPECT_NEAR(m.get_variable_upper_bounds()[x], 5.0, tolerance); +} + +TEST(lp_parser, backslash_comments_are_ignored) +{ + auto m = parse_lp_string(R"LP( +\ This is a comment +Minimize + x \ trailing comment +Subject To \ another comment + c1: x >= 1 +End +)LP"); + int x = find_var(m, "x"); + EXPECT_NEAR(m.get_objective_coefficients()[x], 1.0, tolerance); +} + +TEST(lp_parser, missing_end_warns_but_succeeds) +{ + // No End — should still parse. (A warning is printed; see parse_all().) + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 1 +)LP"); + EXPECT_EQ(m.get_variable_names().size(), 1u); +} + +TEST(lp_parser, auto_generates_names_for_unlabeled_constraints) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + x + y <= 10 + x - y >= 0 +End +)LP"); + ASSERT_EQ(m.get_row_names().size(), 2u); + // Default auto-generated names are R0, R1. + EXPECT_EQ(m.get_row_names()[0], "R0"); + EXPECT_EQ(m.get_row_names()[1], "R1"); +} + +TEST(lp_parser, infinity_keyword_in_bounds) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + c1: x + y >= 0 +Bounds + -inf <= x <= inf + -infinity <= y +End +)LP"); + int x = find_var(m, "x"); + int y = find_var(m, "y"); + EXPECT_TRUE(std::isinf(-m.get_variable_lower_bounds()[x])); + EXPECT_TRUE(std::isinf(m.get_variable_upper_bounds()[x])); + EXPECT_TRUE(std::isinf(-m.get_variable_lower_bounds()[y])); +} + +TEST(lp_parser, coefficient_one_implicit_with_leading_minus) +{ + auto m = parse_lp_string(R"LP( +Minimize + - x + y +Subject To + c1: - x + y <= 0 +End +)LP"); + int x = find_var(m, "x"); + int y = find_var(m, "y"); + EXPECT_NEAR(m.get_objective_coefficients()[x], -1.0, tolerance); + EXPECT_NEAR(m.get_objective_coefficients()[y], 1.0, tolerance); + int r = find_row(m, "c1"); + EXPECT_NEAR(a_entry(m, r, x), -1.0, tolerance); +} + +TEST(lp_parser, quadratic_without_slash_two_is_rejected) +{ + // The quadratic bracket in the objective must be followed by '/ 2'. + // Without it there's no unambiguous way to tell whether the user meant + // '/ 2' and forgot or intended the bare coefficients, so cuopt rejects. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + [ 1 x ^2 ] +Subject To + c1: x >= 1 +End +)LP"), + std::logic_error); +} + +// =========================================================================== +// Quadratic constraints (LHS contains [ ... ] without the /2 divisor). +// =========================================================================== + +// Returns the kth quadratic constraint of `m` (`k` is the 0-indexed position +// in the order quadratic constraints were declared in the LP file). +const auto& nth_qc(const mps_data_model_t& m, size_t k) +{ + const auto& qcs = m.get_quadratic_constraints(); + return qcs.at(k); +} + +TEST(lp_parser, qc_basic_diagonal_only) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + q1: [ x ^ 2 + y ^ 2 ] <= 10 +Bounds + x free + y free +End +)LP"); + ASSERT_EQ(m.get_quadratic_constraints().size(), 1u); + const auto& qc = nth_qc(m, 0); + EXPECT_EQ(qc.constraint_row_name, "q1"); + EXPECT_EQ(qc.constraint_row_type, static_cast(LesserThanOrEqual)); + EXPECT_NEAR(qc.rhs_value, 10.0, tolerance); + EXPECT_TRUE(qc.linear_indices.empty()); + // Q = diag(1, 1). CSR: offsets=[0, 1, 2], indices=[0, 1], values=[1, 1]. + EXPECT_EQ(qc.quadratic_offsets, (std::vector{0, 1, 2})); + ASSERT_EQ(qc.quadratic_values.size(), 2u); + EXPECT_NEAR(qc.quadratic_values[0], 1.0, tolerance); + EXPECT_NEAR(qc.quadratic_values[1], 1.0, tolerance); +} + +TEST(lp_parser, qc_cross_term_splits_symmetrically) +{ + // `4 x*y` in the LP source means coefficient on x_i * x_j = 4 in the + // symmetric x^T Q x. Split into Q[x,y] = Q[y,x] = 2 in the CSR. + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + q1: [ x ^ 2 + 4 x * y + y ^ 2 ] <= 5 +End +)LP"); + ASSERT_EQ(m.get_quadratic_constraints().size(), 1u); + const auto& qc = nth_qc(m, 0); + // Q has 4 entries (all of [[1,2],[2,1]]). + EXPECT_EQ(qc.quadratic_offsets, (std::vector{0, 2, 4})); + ASSERT_EQ(qc.quadratic_values.size(), 4u); + EXPECT_NEAR(qc.quadratic_values[0], 1.0, tolerance); // (0, 0) + EXPECT_NEAR(qc.quadratic_values[1], 2.0, tolerance); // (0, 1) + EXPECT_NEAR(qc.quadratic_values[2], 2.0, tolerance); // (1, 0) + EXPECT_NEAR(qc.quadratic_values[3], 1.0, tolerance); // (1, 1) +} + +TEST(lp_parser, qc_linear_and_quadratic_mixed) +{ + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + q1: 3 x + 2 y + [ x ^ 2 + y ^ 2 ] <= 7 +End +)LP"); + ASSERT_EQ(m.get_quadratic_constraints().size(), 1u); + const auto& qc = nth_qc(m, 0); + EXPECT_NEAR(qc.rhs_value, 7.0, tolerance); + // Linear part: 3 x + 2 y. + ASSERT_EQ(qc.linear_indices.size(), 2u); + ASSERT_EQ(qc.linear_values.size(), 2u); + // Indices may be in any order; check coefficients via lookup. + std::vector xi_yi = {find_var(m, "x"), find_var(m, "y")}; + std::vector expected_coefs; + for (size_t i = 0; i < qc.linear_indices.size(); ++i) { + if (qc.linear_indices[i] == xi_yi[0]) EXPECT_NEAR(qc.linear_values[i], 3.0, tolerance); + if (qc.linear_indices[i] == xi_yi[1]) EXPECT_NEAR(qc.linear_values[i], 2.0, tolerance); + } +} + +TEST(lp_parser, qc_multiple_constraints_indexing) +{ + // 2 linear constraints, then 2 quadratic constraints. Per the data-model + // convention, quadratic rows are indexed after all linear rows. + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + c1: x + y <= 100 + c2: x - y >= -50 + q1: [ x ^ 2 ] <= 1 + q2: [ y ^ 2 ] <= 4 +End +)LP"); + EXPECT_EQ(m.get_row_names().size(), 2u); // linear rows only + ASSERT_EQ(m.get_quadratic_constraints().size(), 2u); + EXPECT_EQ(nth_qc(m, 0).constraint_row_index, 2); + EXPECT_EQ(nth_qc(m, 0).constraint_row_name, "q1"); + EXPECT_EQ(nth_qc(m, 1).constraint_row_index, 3); + EXPECT_EQ(nth_qc(m, 1).constraint_row_name, "q2"); +} + +TEST(lp_parser, qc_outer_minus_sign_flips_quadratic_and_linear) +{ + // `- [ x^2 + 2 x ] + 5` on the LHS contributes -x^2 - 2 x + 5 to the LHS. + // After moving the constant to the RHS: -x^2 - 2 x <= rhs - 5. + // Here the RHS is 10, so the row becomes: -x^2 - 2 x <= 5 (in x^T Q x form + // Q[x,x] = -1). + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + q1: - [ x ^ 2 + 2 x ] + 5 <= 10 +Bounds + x free +End +)LP"); + ASSERT_EQ(m.get_quadratic_constraints().size(), 1u); + const auto& qc = nth_qc(m, 0); + EXPECT_NEAR(qc.rhs_value, 5.0, tolerance); + ASSERT_EQ(qc.quadratic_values.size(), 1u); + EXPECT_NEAR(qc.quadratic_values[0], -1.0, tolerance); + ASSERT_EQ(qc.linear_indices.size(), 1u); + EXPECT_NEAR(qc.linear_values[0], -2.0, tolerance); +} + +TEST(lp_parser, qc_named_constraint) +{ + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + my_quad: [ x ^ 2 ] <= 1 +End +)LP"); + ASSERT_EQ(m.get_quadratic_constraints().size(), 1u); + EXPECT_EQ(nth_qc(m, 0).constraint_row_name, "my_quad"); +} + +TEST(lp_parser, qc_ge_relation_throws) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + q1: [ x ^ 2 ] >= 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, qc_eq_relation_throws) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + q1: [ x ^ 2 ] = 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, qc_with_slash_two_is_rejected) +{ + // '/ 2' is reserved for the objective bracket; using it in a constraint + // bracket is rejected so the convention is unambiguous. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + q1: [ x ^ 2 ] / 2 <= 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, qc_linear_only_bracket_is_rejected) +{ + // A bracket with no quadratic terms inside is meaningless in a constraint + // (the user could just write the linear terms directly). + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + q1: [ 2 x ] <= 5 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, qc_objective_quadratic_still_requires_slash_two) +{ + // Regression: the existing '/ 2' requirement on the objective bracket + // must not change after adding constraint-bracket support. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + [ x ^ 2 ] +Subject To + c1: x >= 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, duplicate_coefficient_accumulates) +{ + // Repeated variable in the objective should sum coefficients. + auto m = parse_lp_string(R"LP( +Minimize + 2 x + 3 x + y +Subject To + c1: x + y >= 1 +End +)LP"); + int x = find_var(m, "x"); + int y = find_var(m, "y"); + EXPECT_NEAR(m.get_objective_coefficients()[x], 5.0, tolerance); + EXPECT_NEAR(m.get_objective_coefficients()[y], 1.0, tolerance); +} + +TEST(lp_parser, subject_to_variant_st_dot) +{ + // 'st.' with a trailing period is a Subject-To synonym in the LP-format + // convention. + auto m = parse_lp_string(R"LP( +Minimize + x +st. + c: x >= 1 +End +)LP"); + EXPECT_EQ(m.get_row_names().size(), 1u); + EXPECT_EQ(m.get_row_names()[0], "c"); +} + +TEST(lp_parser, swapped_relational_operators_eq_lt_and_eq_gt) +{ + // '=<' is an alias for '<=' and '=>' for '>=', in both constraints and + // bounds. Tokenizer must produce LessEq / GreaterEq tokens regardless of + // spelling. + auto m = parse_lp_string(R"LP( +Minimize + x + y +Subject To + c_le: x + y =< 10 + c_ge: x + y => 1 +Bounds + y =< 5 + x => 0 +End +)LP"); + int c_le = find_row(m, "c_le"); + int c_ge = find_row(m, "c_ge"); + EXPECT_TRUE(std::isinf(-m.get_constraint_lower_bounds()[c_le])); + EXPECT_NEAR(m.get_constraint_upper_bounds()[c_le], 10.0, tolerance); + EXPECT_NEAR(m.get_constraint_lower_bounds()[c_ge], 1.0, tolerance); + EXPECT_TRUE(std::isinf(m.get_constraint_upper_bounds()[c_ge])); + int x = find_var(m, "x"); + int y = find_var(m, "y"); + EXPECT_NEAR(m.get_variable_upper_bounds()[y], 5.0, tolerance); + EXPECT_NEAR(m.get_variable_lower_bounds()[x], 0.0, tolerance); +} + +TEST(lp_parser, variable_names_with_special_characters) +{ + // Per the LP-format convention, variable names may contain assorted + // punctuation beyond letters + underscore. The names are treated as + // opaque identifiers; cuopt just has to keep them distinct. + auto m = parse_lp_string(R"LP( +Minimize + x!a + x#b + x$c + x@d + x'e + x~f + x.g + x_h + x|i + x{j} + x(k) + a/b +Subject To + c1: x!a + x#b + x$c + x@d + x'e + x~f + x.g + x_h + x|i + x{j} + x(k) + a/b >= 1 +End +)LP"); + ASSERT_EQ(m.get_variable_names().size(), 12u); + for (const std::string& n : + {"x!a", "x#b", "x$c", "x@d", "x'e", "x~f", "x.g", "x_h", "x|i", "x{j}", "x(k)", "a/b"}) { + EXPECT_GE(find_var(m, n), 0) << "missing variable '" << n << "'"; + } +} + +TEST(lp_parser, negative_upper_without_explicit_lower_throws) +{ + // 'x <= -1' with no explicit lower makes the default lb=0 collide with the + // upper. cuopt rejects rather than accept a silently infeasible problem. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + c: x <= 10 +Bounds + x <= -1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, negative_upper_with_explicit_lower_ok) +{ + // Same test as above, but now the lower bound is explicit: no error. + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + c: x <= 10 +Bounds + x >= -5 + x <= -1 +End +)LP"); + int x = find_var(m, "x"); + EXPECT_NEAR(m.get_variable_lower_bounds()[x], -5.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[x], -1.0, tolerance); +} + +TEST(lp_parser, negative_upper_with_range_bound_ok) +{ + // -5 <= x <= -1 declares both bounds in a single line: no error. + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + c: x <= 10 +Bounds + -5 <= x <= -1 +End +)LP"); + int x = find_var(m, "x"); + EXPECT_NEAR(m.get_variable_lower_bounds()[x], -5.0, tolerance); + EXPECT_NEAR(m.get_variable_upper_bounds()[x], -1.0, tolerance); +} + +// ================================================================================================ +// parse_problem dispatch tests +// +// Verifies the extension-based dispatch used by cuopt_cli and the C API. +// ================================================================================================ + +namespace { + +// Writes `content` to a temp file with the given suffix, parses it via +// parse_problem, removes the file, and returns the resulting model. +mps_data_model_t dispatch_parse(const std::string& content, const std::string& suffix) +{ + std::filesystem::path tmp = std::filesystem::temp_directory_path() / + (std::string{"cuopt_dispatch_test_"} + std::to_string(::getpid()) + + "_" + std::to_string(std::rand()) + suffix); + { + std::ofstream out(tmp); + out << content; + } + auto model = parse_problem(tmp.string()); + std::filesystem::remove(tmp); + return model; +} + +constexpr const char* kTrivialLp = R"LP( +Minimize + x +Subject To + c1: x >= 2.5 +Bounds + x <= 10 +End +)LP"; + +constexpr const char* kTrivialMps = R"MPS(NAME trivial +ROWS + N OBJ + G c1 +COLUMNS + x OBJ 1 + x c1 1 +RHS + RHS1 c1 2.5 +BOUNDS + UP BND1 x 10 +ENDATA +)MPS"; + +} // namespace + +TEST(parse_problem, lp_extension_dispatches_to_lp_parser) +{ + auto m = dispatch_parse(kTrivialLp, ".lp"); + ASSERT_EQ(m.get_variable_names().size(), 1u); + EXPECT_EQ(m.get_variable_names()[0], "x"); + EXPECT_NEAR(m.get_variable_upper_bounds()[0], 10.0, tolerance); +} + +TEST(parse_problem, lp_gz_extension_dispatches_to_lp_parser) +{ + // Real compressed LP fixture; successful parse proves dispatch picked the + // LP path. (Routing a .lp.gz to parse_mps would either fail at + // decompression or fail to parse the LP content as MPS.) + auto m = parse_problem(cuopt::test::get_rapids_dataset_root_dir() + + "/linear_programming/good-mps-1.lp.gz"); + ASSERT_EQ(m.get_variable_names().size(), 2u); + EXPECT_EQ(m.get_variable_names()[0], "VAR1"); +} + +TEST(parse_problem, lp_bz2_extension_dispatches_to_lp_parser) +{ + auto m = parse_problem(cuopt::test::get_rapids_dataset_root_dir() + + "/linear_programming/good-mps-1.lp.bz2"); + ASSERT_EQ(m.get_variable_names().size(), 2u); + EXPECT_EQ(m.get_variable_names()[0], "VAR1"); +} + +TEST(parse_problem, mps_extension_dispatches_to_mps_parser) +{ + auto m = dispatch_parse(kTrivialMps, ".mps"); + ASSERT_EQ(m.get_variable_names().size(), 1u); + EXPECT_EQ(m.get_variable_names()[0], "x"); + EXPECT_NEAR(m.get_variable_upper_bounds()[0], 10.0, tolerance); +} + +TEST(parse_problem, qps_extension_dispatches_to_mps_parser) +{ + // QPS is a superset of MPS; the MPS parser handles both. We just need + // parse_problem to route ".qps" to it. + auto m = dispatch_parse(kTrivialMps, ".qps"); + ASSERT_EQ(m.get_variable_names().size(), 1u); + EXPECT_EQ(m.get_variable_names()[0], "x"); +} + +TEST(parse_problem, mps_gz_extension_dispatches_to_mps_parser) +{ + auto m = parse_problem(cuopt::test::get_rapids_dataset_root_dir() + + "/linear_programming/good-mps-1.mps.gz"); + EXPECT_EQ("good-1", m.get_problem_name()); +} + +TEST(parse_problem, mps_bz2_extension_dispatches_to_mps_parser) +{ + auto m = parse_problem(cuopt::test::get_rapids_dataset_root_dir() + + "/linear_programming/good-mps-1.mps.bz2"); + EXPECT_EQ("good-1", m.get_problem_name()); +} + +TEST(parse_problem, uppercase_lp_extension_dispatches_to_lp_parser) +{ + // Matching is case-insensitive: .LP must still route to parse_lp. + auto m = dispatch_parse(kTrivialLp, ".LP"); + ASSERT_EQ(m.get_variable_names().size(), 1u); + EXPECT_EQ(m.get_variable_names()[0], "x"); +} + +TEST(parse_problem, mixed_case_mps_extension_dispatches_to_mps_parser) +{ + auto m = dispatch_parse(kTrivialMps, ".MpS"); + ASSERT_EQ(m.get_variable_names().size(), 1u); + EXPECT_EQ(m.get_variable_names()[0], "x"); +} + +TEST(parse_problem, unrecognized_extension_throws) +{ + // Extensionless and unrelated suffixes are rejected; case doesn't matter + // (matching is case-insensitive, so ".lpgz" stays rejected too). + for (const char* suffix : {".txt", ".lpgz", ""}) { + SCOPED_TRACE(suffix); + EXPECT_THROW(dispatch_parse(kTrivialLp, suffix), std::logic_error); + } +} + +// =========================================================================== +// MPS-only tests preserved from cpp/tests/linear_programming/mps_parser_test.cpp +// (semi-continuous variables and quadratic-constraint coverage added in #1193; +// kept here because the LP parser does not support these constructs). +// =========================================================================== + +TEST(mps_bounds, standard_var_bounds_0_inf) +{ + auto mps = read_from_mps("linear_programming/free-format-mps-1.mps", false); + + // standard bounds are 0,inf when no var bounds are specified + EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); + EXPECT_EQ(0., mps.variable_lower_bounds[0]); + EXPECT_EQ(0., mps.variable_lower_bounds[1]); + EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); + EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[0]); + EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[1]); +} + +TEST(mps_bounds, only_some_UP_LO_var_bounds) +{ + auto mps = read_from_mps("linear_programming/good-mps-some-var-bounds.mps"); + + // standard bounds are 0,inf when no var bounds are specified + EXPECT_EQ(int(2), mps.variable_lower_bounds.size()); + EXPECT_EQ(-1., mps.variable_lower_bounds[0]); + EXPECT_EQ(0., mps.variable_lower_bounds[1]); + EXPECT_EQ(int(2), mps.variable_upper_bounds.size()); + EXPECT_EQ(std::numeric_limits::infinity(), mps.variable_upper_bounds[0]); + EXPECT_EQ(2., mps.variable_upper_bounds[1]); +} + +TEST(mps_bounds, semi_continuous_var_bounds_from_dataset) +{ + struct Case { + const char* name; + const char* mps; + int n_vars; + double lower; + double upper; + }; + const std::vector cases = { + {"sc_standard", cuopt::test::inline_mps::sc_standard_mps, 2, 2.0, 10.0}, + {"sc_lb_zero", cuopt::test::inline_mps::sc_lb_zero_mps, 2, 0.0, 10.0}, + {"sc_no_ub", cuopt::test::inline_mps::sc_no_ub_mps, 2, 2.0, 1e30}, + }; + + for (const auto& c : cases) { + SCOPED_TRACE(c.name); + auto mps = cuopt::test::inline_mps::parse_inline_mps(c.mps); + const auto& var_types = mps.get_variable_types(); + const auto& lower = mps.get_variable_lower_bounds(); + const auto& upper = mps.get_variable_upper_bounds(); + + ASSERT_EQ(c.n_vars, static_cast(var_types.size())); + EXPECT_EQ('S', var_types[0]); + ASSERT_EQ(c.n_vars, static_cast(lower.size())); + ASSERT_EQ(c.n_vars, static_cast(upper.size())); + EXPECT_DOUBLE_EQ(c.lower, lower[0]); + EXPECT_DOUBLE_EQ(c.upper, upper[0]); + } +} + +TEST(mps_bounds, semi_continuous_missing_lower_defaults_to_zero) +{ + auto mps = cuopt::test::inline_mps::parse_inline_mps(cuopt::test::inline_mps::sc_lb_zero_mps); + const auto& var_types = mps.get_variable_types(); + const auto& lower = mps.get_variable_lower_bounds(); + const auto& upper = mps.get_variable_upper_bounds(); + + ASSERT_EQ(2, static_cast(var_types.size())); + EXPECT_EQ('S', var_types[0]); + ASSERT_EQ(2, static_cast(lower.size())); + ASSERT_EQ(2, static_cast(upper.size())); + EXPECT_DOUBLE_EQ(0.0, lower[0]); + EXPECT_DOUBLE_EQ(10.0, upper[0]); +} + +TEST(mps_bounds, semi_continuous_missing_upper_rejected) +{ + EXPECT_THROW( + cuopt::test::inline_mps::parse_inline_mps(cuopt::test::inline_mps::sc_missing_upper_mps), + std::logic_error); +} + +TEST(mps_bounds, semi_continuous_bound_type) +{ + auto mps = read_from_mps("linear_programming/good-mps-semi-continuous-bound.mps", false); + + ASSERT_EQ(int(2), mps.var_names.size()); + ASSERT_EQ(int(2), mps.var_types.size()); + EXPECT_EQ('S', mps.var_types[0]); + ASSERT_EQ(int(2), mps.variable_lower_bounds.size()); + ASSERT_EQ(int(2), mps.variable_upper_bounds.size()); + EXPECT_DOUBLE_EQ(0.0, mps.variable_lower_bounds[0]); + EXPECT_DOUBLE_EQ(2.0, mps.variable_upper_bounds[0]); +} + +TEST(mps_bounds, invalid_bound_type) +{ + ASSERT_THROW(read_from_mps("linear_programming/bad-mps-bound-1.mps", false), std::logic_error); +} + +TEST(qps_parser, qcmatrix_append_api) +{ + using model_t = mps_data_model_t; + model_t model; + + // Validate default-constructed struct shape. + model_t::quadratic_constraint_t default_qcm; + EXPECT_EQ(0, default_qcm.constraint_row_index); + EXPECT_TRUE(default_qcm.quadratic_values.empty()); + EXPECT_TRUE(default_qcm.quadratic_indices.empty()); + EXPECT_TRUE(default_qcm.quadratic_offsets.empty()); + EXPECT_TRUE(default_qcm.linear_values.empty()); + EXPECT_TRUE(default_qcm.linear_indices.empty()); + EXPECT_EQ(0.0, default_qcm.rhs_value); + + // QC0: [[10, 2], [2, 2]] + const std::vector qc0_values = {10.0, 2.0, 2.0, 2.0}; + const std::vector qc0_indices = {0, 1, 0, 1}; + const std::vector qc0_offsets = {0, 2, 4}; + const std::vector qc0_linear_values = {1.0, 1.0}; + const std::vector qc0_linear_indices = {0, 1}; + model.append_quadratic_constraint(0, + "QC0", + 'L', + qc0_linear_values, + qc0_linear_indices, + 5.0, + qc0_values, + qc0_indices, + qc0_offsets); + + // QC1: [[4, 1], [1, 6]] + const std::vector qc1_values = {4.0, 1.0, 1.0, 6.0}; + const std::vector qc1_indices = {0, 1, 0, 1}; + const std::vector qc1_offsets = {0, 2, 4}; + const std::vector qc1_linear_values = {3.0, 1.0}; + const std::vector qc1_linear_indices = {0, 1}; + model.append_quadratic_constraint(1, + "QC1", + 'L', + qc1_linear_values, + qc1_linear_indices, + 10.0, + qc1_values, + qc1_indices, + qc1_offsets); + + ASSERT_TRUE(model.has_quadratic_constraints()); + const auto& qcs = model.get_quadratic_constraints(); + ASSERT_EQ(2u, qcs.size()); + + EXPECT_EQ(0, qcs[0].constraint_row_index); + EXPECT_EQ("QC0", qcs[0].constraint_row_name); + EXPECT_EQ('L', qcs[0].constraint_row_type); + EXPECT_EQ(qc0_linear_values, qcs[0].linear_values); + EXPECT_EQ(qc0_linear_indices, qcs[0].linear_indices); + EXPECT_EQ(5.0, qcs[0].rhs_value); + EXPECT_EQ(qc0_values, qcs[0].quadratic_values); + EXPECT_EQ(qc0_indices, qcs[0].quadratic_indices); + EXPECT_EQ(qc0_offsets, qcs[0].quadratic_offsets); + + EXPECT_EQ(1, qcs[1].constraint_row_index); + EXPECT_EQ("QC1", qcs[1].constraint_row_name); + EXPECT_EQ('L', qcs[1].constraint_row_type); + EXPECT_EQ(qc1_linear_values, qcs[1].linear_values); + EXPECT_EQ(qc1_linear_indices, qcs[1].linear_indices); + EXPECT_EQ(10.0, qcs[1].rhs_value); + EXPECT_EQ(qc1_values, qcs[1].quadratic_values); + EXPECT_EQ(qc1_indices, qcs[1].quadratic_indices); + EXPECT_EQ(qc1_offsets, qcs[1].quadratic_offsets); +} + +// QCQP MPS: each quadratic constraint bundles row + linear + rhs + quadratic. +TEST(qps_parser, qcmatrix_mps_linear_rhs_and_bounds) +{ + if (!file_exists("qcqp/QC_Test_1.mps")) { + GTEST_SKIP() << "qcqp/QC_Test_1.mps not in dataset root"; + } + const auto model = parse_mps( + cuopt::test::get_rapids_dataset_root_dir() + "/qcqp/QC_Test_1.mps", false); + + ASSERT_TRUE(model.has_quadratic_constraints()); + const auto& qcs = model.get_quadratic_constraints(); + ASSERT_EQ(2u, qcs.size()); + + ASSERT_EQ(1, model.get_n_constraints()); + ASSERT_EQ(1u, model.get_row_names().size()); + EXPECT_EQ("LIN0", model.get_row_names()[0]); + EXPECT_EQ('L', model.get_row_types()[0]); + + // LIN0: 2*x1 + x2 ≤ 15 (linear row only; not duplicated in quadratic_constraints) + EXPECT_DOUBLE_EQ(-std::numeric_limits::infinity(), + model.get_constraint_lower_bounds()[0]); + EXPECT_DOUBLE_EQ(15.0, model.get_constraint_upper_bounds()[0]); + const auto& A_off = model.get_constraint_matrix_offsets(); + const auto& A_val = model.get_constraint_matrix_values(); + const auto& A_idx = model.get_constraint_matrix_indices(); + ASSERT_EQ(2, A_off[1] - A_off[0]); + EXPECT_EQ(2.0, A_val[A_off[0] + 0]); + EXPECT_EQ(1.0, A_val[A_off[0] + 1]); + EXPECT_EQ(0, A_idx[A_off[0] + 0]); + EXPECT_EQ(1, A_idx[A_off[0] + 1]); + + // QC0: x1 + x2 + xᵀQ₀x ≤ 5 (MPS ROWS declaration index 1; OBJ 'N' rows are not counted) + EXPECT_EQ(1, qcs[0].constraint_row_index); + EXPECT_EQ("QC0", qcs[0].constraint_row_name); + EXPECT_EQ('L', qcs[0].constraint_row_type); + ASSERT_EQ(2u, qcs[0].linear_values.size()); + EXPECT_EQ(1.0, qcs[0].linear_values[0]); + EXPECT_EQ(1.0, qcs[0].linear_values[1]); + EXPECT_EQ(0, qcs[0].linear_indices[0]); + EXPECT_EQ(1, qcs[0].linear_indices[1]); + EXPECT_DOUBLE_EQ(5.0, qcs[0].rhs_value); + EXPECT_FALSE(qcs[0].quadratic_values.empty()); + + // QC1: 3*x1 + x2 + xᵀQ₁x ≤ 10 + EXPECT_EQ(2, qcs[1].constraint_row_index); + EXPECT_EQ("QC1", qcs[1].constraint_row_name); + EXPECT_EQ('L', qcs[1].constraint_row_type); + ASSERT_EQ(2u, qcs[1].linear_values.size()); + EXPECT_EQ(3.0, qcs[1].linear_values[0]); + EXPECT_EQ(1.0, qcs[1].linear_values[1]); + EXPECT_DOUBLE_EQ(10.0, qcs[1].rhs_value); +} + +TEST(qps_parser, qcqp_p0033_mps_sections) +{ + if (!file_exists("qcqp/p0033_qc1.mps")) { + GTEST_SKIP() << "qcqp/p0033_qc1.mps not in dataset root"; + } + const auto model = parse_mps( + cuopt::test::get_rapids_dataset_root_dir() + "/qcqp/p0033_qc1.mps", false); + + EXPECT_EQ(12, model.get_n_constraints()); + EXPECT_EQ(33, model.get_n_variables()); + ASSERT_EQ(12u, model.get_row_types().size()); + ASSERT_EQ(12u, model.get_row_names().size()); + + const auto& qcs = model.get_quadratic_constraints(); + ASSERT_EQ(4u, qcs.size()); + EXPECT_EQ(12, qcs[0].constraint_row_index); + ASSERT_EQ(1u, qcs[0].linear_values.size()); + EXPECT_DOUBLE_EQ(1.0, qcs[0].linear_values[0]); + + const auto& vnames = model.get_variable_names(); + auto c159_it = std::find(vnames.begin(), vnames.end(), std::string("C159")); + ASSERT_NE(c159_it, vnames.end()); + EXPECT_EQ(static_cast(c159_it - vnames.begin()), qcs[0].linear_indices[0]); + + EXPECT_DOUBLE_EQ(1.0, qcs[0].rhs_value); + EXPECT_FALSE(qcs[0].quadratic_values.empty()); +} + +TEST(mps_roundtrip, qcqp_p0033_qc1) +{ + if (!file_exists("qcqp/p0033_qc1.mps")) { GTEST_SKIP() << "Test file not found"; } + + std::string input_file = cuopt::test::get_rapids_dataset_root_dir() + "/qcqp/p0033_qc1.mps"; + std::string temp_file = "/tmp/mps_roundtrip_p0033_qc1.mps"; + std::string temp_file_2 = "/tmp/mps_roundtrip_p0033_qc1_r2.mps"; + + auto original = parse_mps(input_file, false); + ASSERT_TRUE(original.has_quadratic_objective()); + ASSERT_TRUE(original.has_quadratic_constraints()); + + mps_writer_t writer(original); + writer.write(temp_file); + + auto reloaded = parse_mps(temp_file, false); + mps_writer_t writer_r2(reloaded); + writer_r2.write(temp_file_2); + auto reloaded_2 = parse_mps(temp_file_2, false); + compare_data_models(reloaded, reloaded_2); + + std::filesystem::remove(temp_file); + std::filesystem::remove(temp_file_2); +} +} // namespace cuopt::linear_programming::io diff --git a/datasets/linear_programming/good-mps-1.lp b/datasets/linear_programming/good-mps-1.lp new file mode 100644 index 0000000000..3d5a98225e --- /dev/null +++ b/datasets/linear_programming/good-mps-1.lp @@ -0,0 +1,12 @@ +\ Equivalent of good-mps-1.mps +\ min 0.2*VAR1 + 0.1*VAR2 +\ s.t. 3*VAR1 + 4*VAR2 <= 5.4 +\ 2.7*VAR1 + 10.1*VAR2 <= 4.9 +\ VAR1, VAR2 >= 0 (default) + +Minimize + 0.2 VAR1 + 0.1 VAR2 +Subject To + ROW1: 3 VAR1 + 4 VAR2 <= 5.4 + ROW2: 2.7 VAR1 + 10.1 VAR2 <= 4.9 +End diff --git a/datasets/linear_programming/good-mps-1.lp.bz2 b/datasets/linear_programming/good-mps-1.lp.bz2 new file mode 100644 index 0000000000..9c4ef6ec26 Binary files /dev/null and b/datasets/linear_programming/good-mps-1.lp.bz2 differ diff --git a/datasets/linear_programming/good-mps-1.lp.gz b/datasets/linear_programming/good-mps-1.lp.gz new file mode 100644 index 0000000000..bf0bd1d763 Binary files /dev/null and b/datasets/linear_programming/good-mps-1.lp.gz differ diff --git a/datasets/linear_programming/good-mps-fixed-var.lp b/datasets/linear_programming/good-mps-fixed-var.lp new file mode 100644 index 0000000000..ab12d0eb5a --- /dev/null +++ b/datasets/linear_programming/good-mps-fixed-var.lp @@ -0,0 +1,11 @@ +\ Equivalent of good-mps-fixed-var.mps +\ VAR1 fixed at 2; VAR2 default [0, inf) + +Minimize + 0.2 VAR1 + 0.1 VAR2 +Subject To + ROW1: 3 VAR1 + 4 VAR2 <= 5.4 + ROW2: 2.7 VAR1 + 10.1 VAR2 <= 4.9 +Bounds + VAR1 = 2 +End diff --git a/datasets/linear_programming/good-mps-free-var.lp b/datasets/linear_programming/good-mps-free-var.lp new file mode 100644 index 0000000000..58077c94dd --- /dev/null +++ b/datasets/linear_programming/good-mps-free-var.lp @@ -0,0 +1,11 @@ +\ Equivalent of good-mps-free-var.mps +\ VAR1 free (-inf, +inf); VAR2 default [0, +inf) + +Minimize + 0.2 VAR1 + 0.1 VAR2 +Subject To + ROW1: 3 VAR1 + 4 VAR2 <= 5.4 + ROW2: 2.7 VAR1 + 10.1 VAR2 <= 4.9 +Bounds + VAR1 free +End diff --git a/datasets/linear_programming/good-mps-lower-bound-inf-var.lp b/datasets/linear_programming/good-mps-lower-bound-inf-var.lp new file mode 100644 index 0000000000..db83d77fd4 --- /dev/null +++ b/datasets/linear_programming/good-mps-lower-bound-inf-var.lp @@ -0,0 +1,11 @@ +\ Equivalent of good-mps-lower-bound-inf-var.mps +\ VAR1 has MI (lower -inf, upper default +inf); VAR2 default [0, +inf) + +Minimize + 0.2 VAR1 + 0.1 VAR2 +Subject To + ROW1: 3 VAR1 + 4 VAR2 <= 5.4 + ROW2: 2.7 VAR1 + 10.1 VAR2 <= 4.9 +Bounds + -inf <= VAR1 +End diff --git a/datasets/linear_programming/good-mps-some-var-bounds.lp b/datasets/linear_programming/good-mps-some-var-bounds.lp new file mode 100644 index 0000000000..3c76a1ae2c --- /dev/null +++ b/datasets/linear_programming/good-mps-some-var-bounds.lp @@ -0,0 +1,12 @@ +\ Equivalent of good-mps-some-var-bounds.mps +\ -1 <= VAR1 <= inf; 0 <= VAR2 <= 2 + +Minimize + 0.2 VAR1 + 0.1 VAR2 +Subject To + ROW1: 3 VAR1 + 4 VAR2 <= 5.4 + ROW2: 2.7 VAR1 + 10.1 VAR2 <= 4.9 +Bounds + -1 <= VAR1 + VAR2 <= 2 +End diff --git a/datasets/linear_programming/good-mps-upper-bound-inf-var.lp b/datasets/linear_programming/good-mps-upper-bound-inf-var.lp new file mode 100644 index 0000000000..014d671e5e --- /dev/null +++ b/datasets/linear_programming/good-mps-upper-bound-inf-var.lp @@ -0,0 +1,12 @@ +\ Equivalent of good-mps-upper-bound-inf-var.mps +\ VAR1 has PL (lower default 0, upper +inf); VAR2 default [0, +inf) +\ Semantically both at default [0, +inf). + +Minimize + 0.2 VAR1 + 0.1 VAR2 +Subject To + ROW1: 3 VAR1 + 4 VAR2 <= 5.4 + ROW2: 2.7 VAR1 + 10.1 VAR2 <= 4.9 +Bounds + VAR1 <= inf +End diff --git a/datasets/linear_programming/lp_model_with_var_bounds.lp b/datasets/linear_programming/lp_model_with_var_bounds.lp new file mode 100644 index 0000000000..246bdfe4ad --- /dev/null +++ b/datasets/linear_programming/lp_model_with_var_bounds.lp @@ -0,0 +1,14 @@ +\ Equivalent of lp_model_with_var_bounds.mps +\ min 2x - y +\ s.t. x + y <= 3 +\ 0 <= x <= 1 +\ 1 <= y <= 2 + +Minimize + 2 x - y +Subject To + con: x + y <= 3 +Bounds + 0 <= x <= 1 + 1 <= y <= 2 +End diff --git a/datasets/mixed_integer_programming/good-mip-mps-1.lp b/datasets/mixed_integer_programming/good-mip-mps-1.lp new file mode 100644 index 0000000000..4f480ed3bf --- /dev/null +++ b/datasets/mixed_integer_programming/good-mip-mps-1.lp @@ -0,0 +1,17 @@ +\ Equivalent of good-mip-mps-1.mps +\ maximize 100*VAR1 + 150*VAR2 +\ s.t. 8000*VAR1 + 4000*VAR2 <= 40000 +\ 15*VAR1 + 30*VAR2 <= 200 +\ 0 <= VAR1, VAR2 <= 10 and integer + +Maximize + 100 VAR1 + 150 VAR2 +Subject To + ROW1: 8000 VAR1 + 4000 VAR2 <= 40000 + ROW2: 15 VAR1 + 30 VAR2 <= 200 +Bounds + VAR1 <= 10 + VAR2 <= 10 +Generals + VAR1 VAR2 +End diff --git a/datasets/mixed_integer_programming/good-mip-mps-no-bounds.lp b/datasets/mixed_integer_programming/good-mip-mps-no-bounds.lp new file mode 100644 index 0000000000..00512d372b --- /dev/null +++ b/datasets/mixed_integer_programming/good-mip-mps-no-bounds.lp @@ -0,0 +1,12 @@ +\ Equivalent of good-mip-mps-no-bounds.mps +\ maximize 100*VAR1 + 150*VAR2; VAR1 integer, VAR2 continuous. +\ MPS defaults VAR1 (unbounded integer) to [0, 1]; LP "Binaries" matches. + +Maximize + 100 VAR1 + 150 VAR2 +Subject To + ROW1: 8000 VAR1 + 4000 VAR2 <= 40000 + ROW2: 15 VAR1 + 30 VAR2 <= 200 +Binaries + VAR1 +End diff --git a/datasets/mixed_integer_programming/good-mip-mps-partial-bounds.lp b/datasets/mixed_integer_programming/good-mip-mps-partial-bounds.lp new file mode 100644 index 0000000000..7b904c8f30 --- /dev/null +++ b/datasets/mixed_integer_programming/good-mip-mps-partial-bounds.lp @@ -0,0 +1,15 @@ +\ Equivalent of good-mip-mps-partial-bounds.mps +\ maximize 100*VAR1 + 150*VAR2 +\ VAR1 integer with no explicit bounds (MPS default [0,1] ⇒ Binaries in LP) +\ VAR2 continuous with [0, 10] from BOUNDS section + +Maximize + 100 VAR1 + 150 VAR2 +Subject To + ROW1: 8000 VAR1 + 4000 VAR2 <= 40000 + ROW2: 15 VAR1 + 30 VAR2 <= 200 +Bounds + VAR2 <= 10 +Binaries + VAR1 +End diff --git a/docs/cuopt/source/cuopt-c/lp-qp-milp/examples/lp_file_example.c b/docs/cuopt/source/cuopt-c/lp-qp-milp/examples/lp_file_example.c new file mode 100644 index 0000000000..43a8fd7502 --- /dev/null +++ b/docs/cuopt/source/cuopt-c/lp-qp-milp/examples/lp_file_example.c @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* + * LP File C API Example + * + * This example demonstrates how to solve an LP problem from an LP format + * file using the cuOpt C API. The same ``cuOptReadProblem`` call handles + * both MPS and LP inputs — the format is dispatched automatically by the + * file extension (case-insensitive): ``.lp`` / ``.lp.gz`` / ``.lp.bz2`` + * go to the LP parser; ``.mps`` / ``.qps`` and their ``.gz`` / ``.bz2`` + * variants go to the MPS parser. + * + * Problem (from LP file): + * Minimize: -0.2*VAR1 + 0.1*VAR2 + * Subject to: + * 3*VAR1 + 4*VAR2 <= 5.4 + * 2.7*VAR1 + 10.1*VAR2 <= 4.9 + * VAR1, VAR2 >= 0 + * + * Expected Output: + * Number of variables: 2 + * Termination status: Optimal (1) + * Objective value: -0.360000 + * x1 = 1.800000 + * x2 = 0.000000 + * + * Build: + * gcc -I $INCLUDE_PATH -L $LIBCUOPT_LIBRARY_PATH -o lp_file_example lp_file_example.c -lcuopt + * + * Run: + * ./lp_file_example sample.lp + */ + +#include +#include +#include + +const char* termination_status_to_string(cuopt_int_t termination_status) +{ + switch (termination_status) { + case CUOPT_TERMINATION_STATUS_OPTIMAL: + return "Optimal"; + case CUOPT_TERMINATION_STATUS_INFEASIBLE: + return "Infeasible"; + case CUOPT_TERMINATION_STATUS_UNBOUNDED: + return "Unbounded"; + case CUOPT_TERMINATION_STATUS_ITERATION_LIMIT: + return "Iteration limit"; + case CUOPT_TERMINATION_STATUS_TIME_LIMIT: + return "Time limit"; + case CUOPT_TERMINATION_STATUS_NUMERICAL_ERROR: + return "Numerical error"; + case CUOPT_TERMINATION_STATUS_PRIMAL_FEASIBLE: + return "Primal feasible"; + case CUOPT_TERMINATION_STATUS_FEASIBLE_FOUND: + return "Feasible found"; + default: + return "Unknown"; + } +} + +cuopt_int_t solve_lp_file(const char* filename) +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + cuopt_int_t status; + cuopt_float_t time; + cuopt_int_t termination_status; + cuopt_float_t objective_value; + cuopt_int_t num_variables; + cuopt_float_t* solution_values = NULL; + + printf("Reading and solving input file: %s\n", filename); + + // Create the problem from the input file. cuOptReadProblem dispatches on + // the file extension (case-insensitive): ``.lp`` / ``.lp.gz`` / ``.lp.bz2`` + // go to the LP parser; ``.mps`` / ``.qps`` and their ``.gz`` / ``.bz2`` + // variants go to the MPS parser. + status = cuOptReadProblem(filename, &problem); + if (status != CUOPT_SUCCESS) { + printf("Error creating problem from input file: %d\n", status); + goto DONE; + } + + status = cuOptGetNumVariables(problem, &num_variables); + if (status != CUOPT_SUCCESS) { + printf("Error getting number of variables: %d\n", status); + goto DONE; + } + + status = cuOptCreateSolverSettings(&settings); + if (status != CUOPT_SUCCESS) { + printf("Error creating solver settings: %d\n", status); + goto DONE; + } + + status = cuOptSetFloatParameter(settings, CUOPT_ABSOLUTE_PRIMAL_TOLERANCE, 0.0001); + if (status != CUOPT_SUCCESS) { + printf("Error setting optimality tolerance: %d\n", status); + goto DONE; + } + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) { + printf("Error solving problem: %d\n", status); + goto DONE; + } + + status = cuOptGetSolveTime(solution, &time); + if (status != CUOPT_SUCCESS) { + printf("Error getting solve time: %d\n", status); + goto DONE; + } + + status = cuOptGetTerminationStatus(solution, &termination_status); + if (status != CUOPT_SUCCESS) { + printf("Error getting termination status: %d\n", status); + goto DONE; + } + + status = cuOptGetObjectiveValue(solution, &objective_value); + if (status != CUOPT_SUCCESS) { + printf("Error getting objective value: %d\n", status); + goto DONE; + } + + printf("\nResults:\n"); + printf("--------\n"); + printf("Number of variables: %d\n", num_variables); + printf("Termination status: %s (%d)\n", + termination_status_to_string(termination_status), + termination_status); + printf("Solve time: %f seconds\n", time); + printf("Objective value: %f\n", objective_value); + + solution_values = (cuopt_float_t*)malloc(num_variables * sizeof(cuopt_float_t)); + status = cuOptGetPrimalSolution(solution, solution_values); + if (status != CUOPT_SUCCESS) { + printf("Error getting solution values: %d\n", status); + goto DONE; + } + + printf("\nPrimal Solution: First 10 solution variables (or fewer if less exist):\n"); + for (cuopt_int_t i = 0; i < (num_variables < 10 ? num_variables : 10); i++) { + printf("x%d = %f\n", i + 1, solution_values[i]); + } + if (num_variables > 10) { + printf("... (showing only first 10 of %d variables)\n", num_variables); + } + +DONE: + free(solution_values); + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + cuOptDestroySolution(&solution); + + return status; +} + +int main(int argc, char* argv[]) +{ + if (argc != 2) { + printf("Usage: %s \n", argv[0]); + return 1; + } + + cuopt_int_t status = solve_lp_file(argv[1]); + + if (status == CUOPT_SUCCESS) { + printf("\nSolver completed successfully!\n"); + return 0; + } else { + printf("\nSolver failed with status: %d\n", status); + return 1; + } +} diff --git a/docs/cuopt/source/cuopt-c/lp-qp-milp/examples/sample.lp b/docs/cuopt/source/cuopt-c/lp-qp-milp/examples/sample.lp new file mode 100644 index 0000000000..40a53ab33d --- /dev/null +++ b/docs/cuopt/source/cuopt-c/lp-qp-milp/examples/sample.lp @@ -0,0 +1,9 @@ +\ Problem name: good-1 +\ Equivalent to sample.mps in this directory. + +Minimize + COST: - 0.2 VAR1 + 0.1 VAR2 +Subject To + ROW1: 3 VAR1 + 4 VAR2 <= 5.4 + ROW2: 2.7 VAR1 + 10.1 VAR2 <= 4.9 +End diff --git a/docs/cuopt/source/cuopt-c/lp-qp-milp/lp-qp-example.rst b/docs/cuopt/source/cuopt-c/lp-qp-milp/lp-qp-example.rst index 07fdc72d58..dc163009ed 100644 --- a/docs/cuopt/source/cuopt-c/lp-qp-milp/lp-qp-example.rst +++ b/docs/cuopt/source/cuopt-c/lp-qp-milp/lp-qp-example.rst @@ -73,6 +73,12 @@ Example With MPS File This example demonstrates how to use the cuOpt linear programming solver in C to solve an MPS file. +The same ``cuOptReadProblem`` call also accepts **LP** format files. The +format is dispatched from the filename extension (case-insensitive): +``.lp`` / ``.lp.gz`` / ``.lp.bz2`` → LP parser; ``.mps`` / ``.qps`` and +their ``.gz`` / ``.bz2`` variants → MPS parser. Unknown extensions are +rejected. See :ref:`lp-file-example-c` for an LP counterpart. + The example code is available at ``examples/cuopt-c/lp/mps_file_example.c`` (:download:`download `): .. literalinclude:: examples/mps_file_example.c @@ -138,6 +144,42 @@ You should see the following output: Solver completed successfully! +.. _lp-file-example-c: + +Example With LP File +-------------------- + +``cuOptReadProblem`` also accepts LP format files. The same function is +used — it dispatches on the file extension (case-insensitive): +``.lp`` / ``.lp.gz`` / ``.lp.bz2`` → LP parser; ``.mps`` / ``.qps`` and +their ``.gz`` / ``.bz2`` variants → MPS parser; unknown extensions are +rejected. See the ``parse_lp`` declaration in +``cuopt/linear_programming/io/parser.hpp`` for the supported subset of +the LP format. + +The example code is available at ``examples/cuopt-c/lp/lp_file_example.c`` (:download:`download `): + +.. literalinclude:: examples/lp_file_example.c + :language: c + :linenos: + +A sample LP file (:download:`download sample.lp `), +equivalent to the MPS sample above: + +.. literalinclude:: examples/sample.lp + :language: text + :linenos: + +Build and run the example + +.. code-block:: bash + + gcc -I $INCLUDE_PATH -L $LIBCUOPT_LIBRARY_PATH -o lp_file_example lp_file_example.c -lcuopt + ./lp_file_example sample.lp + +The output matches the MPS example above (same problem, same objective = -0.36). + + .. _simple-qp-example-c: Simple Quadratic Programming Example diff --git a/docs/cuopt/source/cuopt-cli/cli-examples.rst b/docs/cuopt/source/cuopt-cli/cli-examples.rst index 066f4088e9..68ae0adba1 100644 --- a/docs/cuopt/source/cuopt-cli/cli-examples.rst +++ b/docs/cuopt/source/cuopt-cli/cli-examples.rst @@ -1,6 +1,22 @@ Examples ======== +Input File Format +################# + +``cuopt_cli`` accepts both **MPS** and **LP** format input files. The +format is dispatched automatically from the file extension +(case-insensitive): + +- ``*.lp``, ``*.lp.gz``, ``*.lp.bz2`` → parsed as LP format +- ``*.mps``, ``*.mps.gz``, ``*.mps.bz2`` (and the equivalent ``*.qps`` + variants) → parsed as MPS / QPS + +Any other extension (including no extension) is rejected with an error +listing the supported suffixes. See the ``parse_lp`` / ``parse_mps`` +declarations in ``cuopt/linear_programming/io/parser.hpp`` for the +supported subset of each format. + Basic Usage ########### diff --git a/docs/cuopt/source/cuopt-cli/index.rst b/docs/cuopt/source/cuopt-cli/index.rst index 9322c62bb6..cae126133d 100644 --- a/docs/cuopt/source/cuopt-cli/index.rst +++ b/docs/cuopt/source/cuopt-cli/index.rst @@ -1,7 +1,7 @@ Command Line Interface ====================== -The cuopt_cli is a command-line interface for LP/MILP solvers that accepts MPS format files as input models. It provides command-line arguments to control all solver settings and parameters when solving linear and mixed-integer programming problems. +The cuopt_cli is a command-line interface for LP/MILP solvers that accepts MPS, QPS, or LP format files as input models. The format is dispatched automatically from the file extension (case-insensitive): ``.lp`` (with optional ``.gz`` / ``.bz2``) goes to the LP parser, ``.mps`` / ``.qps`` (with optional ``.gz`` / ``.bz2``) goes to the MPS parser, and unknown extensions are rejected. It provides command-line arguments to control all solver settings and parameters when solving linear and mixed-integer programming problems. .. toctree:: :maxdepth: 3 diff --git a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst index 7bba75d046..12121a4cf8 100644 --- a/docs/cuopt/source/cuopt-server/examples/lp-examples.rst +++ b/docs/cuopt/source/cuopt-server/examples/lp-examples.rst @@ -202,10 +202,13 @@ The response would be as follows: } -Using MPS file directly ------------------------ +Using MPS or LP file directly +----------------------------- -An example on using .mps files as input is shown below: +The self-hosted client accepts both MPS and LP format files — the client +dispatches on the file extension (``.lp`` ⇒ LP parser, otherwise MPS) and +sends the parsed data model to the server. An example on using .mps files +as input is shown below: :download:`mps_file_example.py ` diff --git a/docs/cuopt/source/hidden/mps-api.rst b/docs/cuopt/source/hidden/mps-api.rst index 664077b451..362a26a51a 100644 --- a/docs/cuopt/source/hidden/mps-api.rst +++ b/docs/cuopt/source/hidden/mps-api.rst @@ -1,8 +1,13 @@ =============================== -cuOpt MPS Parser API Reference +cuOpt MPS/LP Parser API Reference =============================== MPS Parser ---------- .. autofunction:: cuopt.linear_programming.mps_parser.ParseMps + +LP Parser +--------- + +.. autofunction:: cuopt.linear_programming.mps_parser.ParseLp diff --git a/python/cuopt/cuopt/linear_programming/__init__.py b/python/cuopt/cuopt/linear_programming/__init__.py index c88490f866..d171b12878 100644 --- a/python/cuopt/cuopt/linear_programming/__init__.py +++ b/python/cuopt/cuopt/linear_programming/__init__.py @@ -3,7 +3,7 @@ from cuopt.linear_programming import internals from cuopt.linear_programming.data_model import DataModel -from cuopt.linear_programming.mps_parser import ParseMps +from cuopt.linear_programming.mps_parser import ParseLp, ParseMps from cuopt.linear_programming.problem import Problem from cuopt.linear_programming.solution import Solution from cuopt.linear_programming.solver import BatchSolve, Solve diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/__init__.py b/python/cuopt/cuopt/linear_programming/mps_parser/__init__.py index c61013bf50..ded8d5a06c 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/__init__.py +++ b/python/cuopt/cuopt/linear_programming/mps_parser/__init__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from cuopt.linear_programming.mps_parser.parser import ParseMps, toDict +from cuopt.linear_programming.mps_parser.parser import ParseLp, ParseMps, toDict diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/parser.pxd b/python/cuopt/cuopt/linear_programming/mps_parser/parser.pxd index b4875a0bca..402873f9ab 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/parser.pxd +++ b/python/cuopt/cuopt/linear_programming/mps_parser/parser.pxd @@ -37,9 +37,13 @@ cdef extern from "cuopt/linear_programming/io/mps_data_model.hpp" namespace "cuo string objective_name_ string problem_name_ -cdef extern from "cuopt/linear_programming/io/utilities/cython_mps_parser.hpp" namespace "cuopt::cython": # noqa +cdef extern from "cuopt/linear_programming/io/utilities/cython_parser.hpp" namespace "cuopt::cython": # noqa cdef unique_ptr[mps_data_model_t[int, double]] call_parse_mps( const string& mps_file_path, bool fixed_mps_format ) except + + + cdef unique_ptr[mps_data_model_t[int, double]] call_parse_lp( + const string& lp_file_path + ) except + diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/parser.py b/python/cuopt/cuopt/linear_programming/mps_parser/parser.py index 5e83b27ddb..3107b106b2 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/parser.py +++ b/python/cuopt/cuopt/linear_programming/mps_parser/parser.py @@ -13,6 +13,10 @@ def ParseMps(mps_file_path, fixed_mps_format=False): """ Reads the equation from the input text file which is MPS formatted + See Also + -------- + ParseLp : parses LP format files (for users with .lp inputs). + Notes ----- Read this link http://lpsolve.sourceforge.net/5.5/mps-format.htm for more @@ -50,6 +54,50 @@ def ParseMps(mps_file_path, fixed_mps_format=False): return parser_wrapper.ParseMps(mps_file_path, fixed_mps_format) +@catch_mps_parser_exception +def ParseLp(lp_file_path): + """ + Reads an optimization problem from a file in LP format. + + The LP format is a human-readable alternative to MPS and supports LP, + MIP, and QP, plus semi-continuous variables (declared via a + Semi-Continuous section; finite upper bound required) and + quadratic constraints (QCQP; ``<=`` only). + + Quadratic terms live in ``[ ... ]`` blocks. The objective bracket must + be followed by ``/ 2`` (the file states coefficients in the + ``0.5 x^T Q x`` convention); a constraint bracket must NOT be followed + by ``/ 2`` (coefficients are at face value, ``x^T Q x``). + + This function parses the conventional LP dialect implemented by most + commercial optimization solvers (not the lpsolve variant, which has a + different syntax). + + Unsupported LP sections (SOS, PWL objective, user cuts, general + constraints) raise a ValueError. + + Parameters + ---------- + lp_file_path : str + Path to LP-formatted file. + + Returns + ------- + data_model: DataModel + A fully formed LP/MIP/QP problem representing the given file. + + Examples + -------- + >>> from cuopt import linear_programming + >>> + >>> data_model = linear_programming.ParseLp(lp_file_path) + >>> solver_settings = linear_programming.SolverSettings() + >>> solution = linear_programming.Solve(data_model, solver_settings) + """ + + return parser_wrapper.ParseLp(lp_file_path) + + def toDict(model, json=False): if not isinstance(model, parser_wrapper.DataModel): raise ValueError( diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx b/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx index ffd6ac43f3..9026750d47 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx @@ -16,7 +16,7 @@ from libcpp.memory cimport unique_ptr from libcpp.string cimport string from libcpp.utility cimport move -from .parser cimport call_parse_mps +from .parser cimport call_parse_lp, call_parse_mps, mps_data_model_t import warnings @@ -31,98 +31,90 @@ def type_cast(np_obj, np_type, name): return np_obj -@catch_mps_parser_exception -def ParseMps(mps_file_path, fixed_mps_formats): - data_model = DataModel() - - dm_ret_ptr = move( - call_parse_mps( - mps_file_path.encode('utf-8'), - fixed_mps_formats - ) - ) - dm_ret = move(dm_ret_ptr.get()[0]) - - A_values_data = dm_ret.A_.data() - A_values_size = dm_ret.A_.size() +# Copies the C++ data model behind `dm` into the Python-side `data_model`. +# Extracted from ParseMps so ParseLp shares the same marshaling path — +# every field on mps_data_model_t is format-agnostic. +cdef _marshal_data_model(mps_data_model_t[int, double]* dm, data_model): + A_values_data = dm.A_.data() + A_values_size = dm.A_.size() cdef double[:] A_values_ = A_values_data A_values = np.asarray(A_values_).copy() - A_indices_data = dm_ret.A_indices_.data() - A_indices_size = dm_ret.A_indices_.size() + A_indices_data = dm.A_indices_.data() + A_indices_size = dm.A_indices_.size() cdef int[:] A_indices_ = A_indices_data A_indices = np.asarray(A_indices_).copy() - A_offsets_data = dm_ret.A_offsets_.data() - A_offsets_size = dm_ret.A_offsets_.size() + A_offsets_data = dm.A_offsets_.data() + A_offsets_size = dm.A_offsets_.size() cdef int[:] A_offsets_ = A_offsets_data A_offsets = np.asarray(A_offsets_).copy() - b_data = dm_ret.b_.data() - b_size = dm_ret.b_.size() + b_data = dm.b_.data() + b_size = dm.b_.size() cdef double[:] b_ = b_data b = np.asarray(b_).copy() - c_data = dm_ret.c_.data() - c_size = dm_ret.c_.size() + c_data = dm.c_.data() + c_size = dm.c_.size() cdef double[:] c_ = c_data c = np.asarray(c_).copy() - Q_values_size = dm_ret.Q_objective_values_.size() + Q_values_size = dm.Q_objective_values_.size() if Q_values_size > 0: - Q_values_data = dm_ret.Q_objective_values_.data() + Q_values_data = dm.Q_objective_values_.data() Q_values = np.asarray(Q_values_data).copy() else: Q_values = np.array([], dtype=np.float64) - Q_indices_size = dm_ret.Q_objective_indices_.size() + Q_indices_size = dm.Q_objective_indices_.size() if Q_indices_size > 0: - Q_indices_data = dm_ret.Q_objective_indices_.data() + Q_indices_data = dm.Q_objective_indices_.data() Q_indices = np.asarray(Q_indices_data).copy() else: Q_indices = np.array([], dtype=np.int32) - Q_offsets_size = dm_ret.Q_objective_offsets_.size() + Q_offsets_size = dm.Q_objective_offsets_.size() if Q_offsets_size > 0: - Q_offsets_data = dm_ret.Q_objective_offsets_.data() + Q_offsets_data = dm.Q_objective_offsets_.data() Q_offsets = np.asarray(Q_offsets_data).copy() else: Q_offsets = np.array([], dtype=np.int32) - variable_lower_bounds_data = dm_ret.variable_lower_bounds_.data() - variable_lower_bounds_size = dm_ret.variable_lower_bounds_.size() + variable_lower_bounds_data = dm.variable_lower_bounds_.data() + variable_lower_bounds_size = dm.variable_lower_bounds_.size() cdef double[:] variable_lower_bounds_ = variable_lower_bounds_data # noqa variable_lower_bounds = np.asarray(variable_lower_bounds_).copy() - variable_upper_bounds_data = dm_ret.variable_upper_bounds_.data() - variable_upper_bounds_size = dm_ret.variable_upper_bounds_.size() + variable_upper_bounds_data = dm.variable_upper_bounds_.data() + variable_upper_bounds_size = dm.variable_upper_bounds_.size() cdef double[:] variable_upper_bounds_ = variable_upper_bounds_data # noqa variable_upper_bounds = np.asarray(variable_upper_bounds_).copy() - constraint_lower_bounds_data = dm_ret.constraint_lower_bounds_.data() - constraint_lower_bounds_size = dm_ret.constraint_lower_bounds_.size() + constraint_lower_bounds_data = dm.constraint_lower_bounds_.data() + constraint_lower_bounds_size = dm.constraint_lower_bounds_.size() cdef double[:] constraint_lower_bounds_ = constraint_lower_bounds_data # noqa constraint_lower_bounds = np.asarray(constraint_lower_bounds_).copy() - constraint_upper_bounds_data = dm_ret.constraint_upper_bounds_.data() - constraint_upper_bounds_size = dm_ret.constraint_upper_bounds_.size() + constraint_upper_bounds_data = dm.constraint_upper_bounds_.data() + constraint_upper_bounds_size = dm.constraint_upper_bounds_.size() cdef double[:] constraint_upper_bounds_ = constraint_upper_bounds_data # noqa constraint_upper_bounds = np.asarray(constraint_upper_bounds_).copy() - var_types_data = dm_ret.var_types_.data() - var_types_size = dm_ret.var_types_.size() + var_types_data = dm.var_types_.data() + var_types_size = dm.var_types_.size() cdef char[:] var_types_ = var_types_data # noqa var_types = np.asarray(var_types_, dtype='str').copy() - row_types_data = dm_ret.row_types_.data() - row_types_size = dm_ret.row_types_.size() + row_types_data = dm.row_types_.data() + row_types_size = dm.row_types_.size() cdef char[:] row_types_ if row_types_size > 0: row_types_ = row_types_data # noqa row_types = np.asarray(row_types_, dtype='str').copy() else: row_types = None - var_names_ = np.asarray([i.decode() for i in dm_ret.var_names_]) - row_names_ = np.asarray([i.decode() for i in dm_ret.row_names_]) + var_names_ = np.asarray([i.decode() for i in dm.var_names_]) + row_names_ = np.asarray([i.decode() for i in dm.row_names_]) data_model.set_csr_constraint_matrix(A_values, A_indices, A_offsets) data_model.set_constraint_bounds(b) @@ -131,16 +123,37 @@ def ParseMps(mps_file_path, fixed_mps_formats): data_model.set_variable_upper_bounds(variable_upper_bounds) data_model.set_constraint_lower_bounds(constraint_lower_bounds) data_model.set_constraint_upper_bounds(constraint_upper_bounds) - data_model.set_maximize(dm_ret.maximize_) - data_model.set_objective_scaling_factor(dm_ret.objective_scaling_factor_) - data_model.set_objective_offset(dm_ret.objective_offset_) + data_model.set_maximize(dm.maximize_) + data_model.set_objective_scaling_factor(dm.objective_scaling_factor_) + data_model.set_objective_offset(dm.objective_offset_) data_model.set_quadratic_objective_matrix(Q_values, Q_indices, Q_offsets) data_model.set_variable_types(var_types) if row_types is not None: data_model.set_row_types(row_types) data_model.set_variable_names(var_names_) data_model.set_row_names(row_names_) - data_model.set_objective_name(dm_ret.objective_name_.decode()) - data_model.set_problem_name(dm_ret.problem_name_.decode()) + data_model.set_objective_name(dm.objective_name_.decode()) + data_model.set_problem_name(dm.problem_name_.decode()) return data_model + + +@catch_mps_parser_exception +def ParseMps(mps_file_path, fixed_mps_formats): + data_model = DataModel() + dm_ret_ptr = move( + call_parse_mps( + mps_file_path.encode('utf-8'), + fixed_mps_formats + ) + ) + return _marshal_data_model(dm_ret_ptr.get(), data_model) + + +@catch_mps_parser_exception +def ParseLp(lp_file_path): + data_model = DataModel() + dm_ret_ptr = move( + call_parse_lp(lp_file_path.encode('utf-8')) + ) + return _marshal_data_model(dm_ret_ptr.get(), data_model) diff --git a/python/cuopt/cuopt/tests/linear_programming/test_parser.py b/python/cuopt/cuopt/tests/linear_programming/test_parser.py index 53757a3abf..f1b569a092 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_parser.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_parser.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import os +import tempfile from cuopt.linear_programming import mps_parser import numpy as np @@ -67,3 +68,119 @@ def test_good_mps_file(): assert 5.4 == data_model.get_constraint_upper_bounds()[0] assert 4.9 == data_model.get_constraint_upper_bounds()[1] + + +# Minimal LP content that should parse identically regardless of whether it's +# routed through ParseLp() or the server's extension-based dispatch path. +_MINIMAL_LP = """ +Minimize + x +Subject To + c1: x >= 2.5 +Bounds + x <= 10 +End +""" + + +def test_parse_lp_basic(): + with tempfile.NamedTemporaryFile( + suffix=".lp", mode="w", delete=False + ) as f: + f.write(_MINIMAL_LP) + path = f.name + try: + data_model = mps_parser.ParseLp(path) + finally: + os.unlink(path) + + # Minimize ⇒ sense is False. + assert not data_model.get_sense() + # Single variable with default lb=0, explicit ub=10. + assert data_model.get_variable_names().tolist() == ["x"] + assert data_model.get_variable_lower_bounds()[0] == 0.0 + assert data_model.get_variable_upper_bounds()[0] == 10.0 + # Objective is just "x" ⇒ c = [1.0]. + assert data_model.get_objective_coefficients()[0] == 1.0 + # Single >= constraint c1: x >= 2.5. + assert data_model.get_row_names().tolist() == ["c1"] + assert data_model.get_constraint_lower_bounds()[0] == 2.5 + assert np.isinf(data_model.get_constraint_upper_bounds()[0]) + assert data_model.get_constraint_matrix_values().tolist() == [1.0] + + +def test_parse_lp_rejects_unsupported_section(): + # SOS is explicitly out of scope; the parser should raise. + bad_lp = """ +Minimize + x +Subject To + c1: x >= 1 +SOS + s1: S1 :: x : 1 +End +""" + with tempfile.NamedTemporaryFile( + suffix=".lp", mode="w", delete=False + ) as f: + f.write(bad_lp) + path = f.name + try: + with pytest.raises(InputValidationError): + mps_parser.ParseLp(path) + finally: + os.unlink(path) + + +def test_parse_lp_and_parse_mps_agree_on_trivial_problem(): + # Same problem written in LP and MPS — both parsers should produce the + # same data model (modulo variable/constraint ordering, but this problem + # has exactly one of each). + mps_text = ( + "NAME trivial\n" + "ROWS\n" + " N OBJ\n" + " G c1\n" + "COLUMNS\n" + " x OBJ 1\n" + " x c1 1\n" + "RHS\n" + " RHS1 c1 2.5\n" + "BOUNDS\n" + " UP BND1 x 10\n" + "ENDATA\n" + ) + with tempfile.NamedTemporaryFile( + suffix=".mps", mode="w", delete=False + ) as f: + f.write(mps_text) + mps_path = f.name + with tempfile.NamedTemporaryFile( + suffix=".lp", mode="w", delete=False + ) as f: + f.write(_MINIMAL_LP) + lp_path = f.name + try: + lp_model = mps_parser.ParseLp(lp_path) + mps_model = mps_parser.ParseMps(mps_path) + finally: + os.unlink(mps_path) + os.unlink(lp_path) + + assert lp_model.get_sense() == mps_model.get_sense() + assert ( + lp_model.get_variable_names().tolist() + == mps_model.get_variable_names().tolist() + ) + assert ( + lp_model.get_objective_coefficients().tolist() + == mps_model.get_objective_coefficients().tolist() + ) + assert ( + lp_model.get_variable_upper_bounds().tolist() + == mps_model.get_variable_upper_bounds().tolist() + ) + assert ( + lp_model.get_constraint_lower_bounds().tolist() + == mps_model.get_constraint_lower_bounds().tolist() + ) diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py index d5c9f711e9..ae59b84f5a 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py @@ -134,25 +134,33 @@ def is_uuid(cuopt_problem_data): return False -def _mps_parse(LP_problem_data, solver_config): +def _parse_file_to_data_model(problem_input, solver_config): try: from cuopt.linear_programming import mps_parser except ImportError as e: raise ImportError( - "MPS parsing on the client requires the cuopt package. " + "MPS/LP parsing on the client requires the cuopt package. " "Install it with `pip install cuopt-sh-client[mps]` (or " "`pip install cuopt-cu13` / `cuopt-cu12` matching your CUDA), " - "or pass an already-parsed dict instead of an MPS file or " + "or pass an already-parsed dict instead of an MPS/LP file or " "DataModel." ) from e - if isinstance(LP_problem_data, mps_parser.parser_wrapper.DataModel): - model = LP_problem_data - log.debug("Received Mps parser DataModel object") + # problem_input is either a path (str) to an MPS/LP file, or an + # mps_parser DataModel already handed to us. + if isinstance(problem_input, mps_parser.parser_wrapper.DataModel): + model = problem_input + log.debug("Received mps_parser DataModel object") else: t0 = time.time() - model = mps_parser.ParseMps(LP_problem_data) + # Dispatch on file extension: ".lp" ⇒ LP parser, otherwise MPS. + if isinstance(problem_input, str) and problem_input.lower().endswith( + ".lp" + ): + model = mps_parser.ParseLp(problem_input) + else: + model = mps_parser.ParseMps(problem_input) parse_time = time.time() - t0 - log.debug(f"mps_parsing time was {parse_time}") + log.debug(f"file parsing time was {parse_time}") problem_data = mps_parser.toDict(model, json=use_zlib) if type(solver_config) is dict: @@ -732,14 +740,14 @@ def get_LP_solve( cuopt_data_models : Note - Batch mode is only supported in LP and not in MILP - File path to mps or json/dict/DataModel returned by - cuopt.linear_programming.mps_parser/list[mps file paths]/list[dict]/list[DataModel]. + File path to mps/lp or json/dict/DataModel returned by + cuopt.linear_programming.mps_parser/list[mps or lp file paths]/list[dict]/list[DataModel]. - For single problem, input should be either a path to mps/json file, + For single problem, input should be either a path to mps/lp/json file, /DataModel returned by cuopt.linear_programming.mps_parser/ path to json file/ dictionary. - For batch problem, input should be either a list of paths to mps + For batch problem, input should be either a list of paths to mps or lp files/ a list of DataModel returned by cuopt.linear_programming.mps_parser/ a list of dictionaries. @@ -798,22 +806,29 @@ def get_LP_solve( def read_cuopt_problem_data(cuopt_data_model, filepath): if isinstance(cuopt_data_model, dict): - mps = False + needs_parsing = False filepath = False else: - mps = ( - isinstance(cuopt_data_model, str) - and cuopt_data_model.endswith(".mps") - ) or not isinstance(cuopt_data_model, str) + # Needs parsing if it's either (a) a string path ending in + # .mps/.lp, or (b) a non-string (DataModel) to normalize. + if isinstance(cuopt_data_model, str): + lowered = cuopt_data_model.lower() + needs_parsing = lowered.endswith( + ".mps" + ) or lowered.endswith(".lp") + else: + needs_parsing = True - if mps: + if needs_parsing: if filepath: raise ValueError( - "Cannot use local file mode with MPS data. " + "Cannot use local file mode with MPS/LP data. " "Resubmit with filepath=False." ) - cuopt_data_model = _mps_parse(cuopt_data_model, solver_config) + cuopt_data_model = _parse_file_to_data_model( + cuopt_data_model, solver_config + ) elif filepath and cuopt_data_model.startswith("/"): log.warning(