From 92ffa8f52519bba5f39b20d30fbf73d2ac015b9f Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Fri, 15 May 2026 18:57:16 +0000 Subject: [PATCH 01/18] Add LP format reader; accept .lp wherever .mps is accepted cuOptReadProblem, cuopt_cli, the Python ParseLp() wrapper, and the self-hosted client now dispatch on the input filename: a case-insensitive ".lp" suffix routes to a new LP parser; everything else (including .mps, .mps.gz, .mps.bz2, and extensionless inputs) continues to use parse_mps. The LP parser supports LP, MIP, and QP problems in the conventional LP dialect used by most commercial solvers (not the lpsolve variant). SOS, PWL, semi-continuous, user-cut, and general-constraint sections raise a ValidationError rather than silently mis-parsing. Quadratic-constraint support (QCMATRIX) remains MPS-only. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Miles Lubin --- cpp/cuopt_cli.cpp | 29 +- .../cuopt/linear_programming/cuopt_c.h | 12 +- .../cuopt/linear_programming/io/parser.hpp | 88 + ...ython_mps_parser.hpp => cython_parser.hpp} | 3 + cpp/src/io/CMakeLists.txt | 4 +- cpp/src/io/file_to_string.cpp | 246 ++ cpp/src/io/file_to_string.hpp | 24 + cpp/src/io/lp_parser.cpp | 1393 +++++++++ cpp/src/io/lp_parser.hpp | 84 + cpp/src/io/mps_parser.cpp | 226 +- cpp/src/io/mps_parser_internal.hpp | 6 - cpp/src/io/parser_finalize.hpp | 255 ++ ...ython_mps_parser.cpp => cython_parser.cpp} | 9 +- cpp/src/pdlp/cuopt_c.cpp | 11 +- cpp/tests/linear_programming/CMakeLists.txt | 4 +- .../c_api_tests/c_api_tests.cpp | 39 + .../linear_programming/mps_parser_test.cpp | 1401 --------- cpp/tests/linear_programming/parser_test.cpp | 2751 +++++++++++++++++ datasets/linear_programming/good-mps-1.lp | 12 + datasets/linear_programming/good-mps-1.lp.bz2 | Bin 0 -> 215 bytes datasets/linear_programming/good-mps-1.lp.gz | Bin 0 -> 199 bytes .../linear_programming/good-mps-fixed-var.lp | 11 + .../linear_programming/good-mps-free-var.lp | 11 + .../good-mps-lower-bound-inf-var.lp | 11 + .../good-mps-some-var-bounds.lp | 12 + .../good-mps-upper-bound-inf-var.lp | 12 + .../lp_model_with_var_bounds.lp | 14 + .../good-mip-mps-1.lp | 17 + .../good-mip-mps-no-bounds.lp | 12 + .../good-mip-mps-partial-bounds.lp | 15 + .../lp-qp-milp/examples/lp_file_example.c | 179 ++ .../cuopt-c/lp-qp-milp/examples/sample.lp | 9 + .../cuopt-c/lp-qp-milp/lp-qp-example.rst | 42 + docs/cuopt/source/cuopt-cli/cli-examples.rst | 16 + docs/cuopt/source/cuopt-cli/index.rst | 2 +- .../cuopt-server/examples/lp-examples.rst | 9 +- docs/cuopt/source/hidden/mps-api.rst | 7 +- .../cuopt/linear_programming/__init__.py | 2 +- .../linear_programming/mps_parser/__init__.py | 2 +- .../linear_programming/mps_parser/parser.pxd | 6 +- .../linear_programming/mps_parser/parser.py | 48 + .../mps_parser/parser_wrapper.pyx | 109 +- .../tests/linear_programming/test_parser.py | 117 + .../cuopt_sh_client/cuopt_self_host_client.py | 55 +- 44 files changed, 5584 insertions(+), 1731 deletions(-) rename cpp/include/cuopt/linear_programming/io/utilities/{cython_mps_parser.hpp => cython_parser.hpp} (80%) create mode 100644 cpp/src/io/file_to_string.cpp create mode 100644 cpp/src/io/file_to_string.hpp create mode 100644 cpp/src/io/lp_parser.cpp create mode 100644 cpp/src/io/lp_parser.hpp create mode 100644 cpp/src/io/parser_finalize.hpp rename cpp/src/io/utilities/{cython_mps_parser.cpp => cython_parser.cpp} (64%) delete mode 100644 cpp/tests/linear_programming/mps_parser_test.cpp create mode 100644 cpp/tests/linear_programming/parser_test.cpp create mode 100644 datasets/linear_programming/good-mps-1.lp create mode 100644 datasets/linear_programming/good-mps-1.lp.bz2 create mode 100644 datasets/linear_programming/good-mps-1.lp.gz create mode 100644 datasets/linear_programming/good-mps-fixed-var.lp create mode 100644 datasets/linear_programming/good-mps-free-var.lp create mode 100644 datasets/linear_programming/good-mps-lower-bound-inf-var.lp create mode 100644 datasets/linear_programming/good-mps-some-var-bounds.lp create mode 100644 datasets/linear_programming/good-mps-upper-bound-inf-var.lp create mode 100644 datasets/linear_programming/lp_model_with_var_bounds.lp create mode 100644 datasets/mixed_integer_programming/good-mip-mps-1.lp create mode 100644 datasets/mixed_integer_programming/good-mip-mps-no-bounds.lp create mode 100644 datasets/mixed_integer_programming/good-mip-mps-partial-bounds.lp create mode 100644 docs/cuopt/source/cuopt-c/lp-qp-milp/examples/lp_file_example.c create mode 100644 docs/cuopt/source/cuopt-c/lp-qp-milp/examples/sample.lp 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 0000000000000000000000000000000000000000..9c4ef6ec266d5bdae575d66374c410fd9ceee473 GIT binary patch literal 215 zcmV;|04V=LT4*^jL0KkKS^bZHmH+^3Uw{Y@K!1L>A_AR+Kez7?FaWHBQ$jT~^*u+F z&^<#zr>Fxo$qWDu8fXJ1fY4+HfRs&0-l?EHAPqFrL7)H*n0{?)Y7rE8sNIxeHk!en z;Z4U68xAM|W)&!4jqgp;Iyy+|E4#S%#Dw#_M4*^*SzLmun}moiwk%{x2BJs|D2f*) zV5*>nf+nae#bP52rD{Y(2#AQF5Me#jx23SgHeo`Ul3x~k??p1<$oCTB#Y-?$!e-Yp Rc)LLUF64@Ep&|Po|178jTD|}P literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bf0bd1d76395bdb6f071ddb492cfb392744ea0d2 GIT binary patch literal 199 zcmV;&06702iwFqcatCSv17~kR#J9QQoOy;tP#UVX|&Y70~2VN4aZuP$PA=;R8tN+YIx zcHR{X^I>Fefu3%AY@bnP667Vyh$h8UAd)@9$=DIt5M zRja;|!2QSnzt_pJzdhyM;$#M)I9ynYBuE0|C?50FMZqWv#!+%zkspz&(E1Dk005>* BTp$1d literal 0 HcmV?d00001 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( From 7e944b19658fd22209d54ef6f01476d76355808d Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 17:07:13 +0000 Subject: [PATCH 02/18] ci: update expected error substring for MPS/LP local-file rejection The error message in cuopt_self_host_client.py was updated to say "MPS/LP data" alongside the LP reader changes, but the matching assertion in test_self_hosted_service.sh was not, causing CLI test 7 to fail. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- ci/test_self_hosted_service.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/test_self_hosted_service.sh b/ci/test_self_hosted_service.sh index 6ecd1bcbd3..05db33b1ee 100755 --- a/ci/test_self_hosted_service.sh +++ b/ci/test_self_hosted_service.sh @@ -120,7 +120,7 @@ if [ "$doservertest" -eq 1 ]; then run_cli_test "'status': 'Optimal'" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP ../../datasets/linear_programming/good-mps-1.mps ../../datasets/linear_programming/good-mps-1.mps # Error, local file mode is not allowed with mps - run_cli_test "Cannot use local file mode with MPS data" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP -f good-mps-1.mps + run_cli_test "Cannot use local file mode with MPS/LP data" cuopt_sh -s -c "$CLIENT_CERT" -p $CUOPT_SERVER_PORT -t LP -f good-mps-1.mps # Just run validator cp ../../datasets/cuopt_service_data/cuopt_problem_data.json "$CUOPT_DATA_DIR" From 16c1c3da31de77eb139d46cc6365afaa41828362 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 18:23:58 +0000 Subject: [PATCH 03/18] Reject ambiguous ' [' and stray ' *' in LP parser parse_linear_expression silently accepted two malformed inputs: - '2 [ x^2 ] / 2' parsed as objective_offset=2 plus quadratic x^2, rather than rejecting the unsupported scalar-multiplication-of-bracket form. Same shape occurs in quadratic constraint rows. - ' *' followed by a relation, section header, or EOL silently dropped the '*' and turned the number into a bare constant. Both now raise a ValidationError pointing at the offending line. Added tests for both malformed forms plus the legitimate forms ('5 + [...]/2' and '3 * x') to pin the boundary. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/src/io/lp_parser.cpp | 28 +++++++- cpp/tests/linear_programming/parser_test.cpp | 72 ++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/cpp/src/io/lp_parser.cpp b/cpp/src/io/lp_parser.cpp index 7341c7fa9e..91a1eec2d3 100644 --- a/cpp/src/io/lp_parser.cpp +++ b/cpp/src/io/lp_parser.cpp @@ -764,11 +764,37 @@ void LpParseEngine::parse_linear_expression(std::vector& o // 'inf' is a bounds-only keyword and never appears here. f_t coeff = f_t(1); bool had_coeff = false; + bool had_star = false; if (peek().kind == LpTokenKind::Number) { coeff = number_from_text(peek().text); had_coeff = true; advance(); - match(LpTokenKind::Star); // optional '*' + had_star = match(LpTokenKind::Star); + } + + // ' *' must be followed by a variable name; a stray '*' before a + // relation, section header, or EOL would otherwise be silently dropped + // (and the number would be misinterpreted as a constant). + if (had_star) { + mps_parser_expects( + peek().kind == LpTokenKind::Name && !at_section_boundary() && !is_infinity_keyword(peek()), + error_type_t::ValidationError, + "LP parse error at line %d: expected variable name after '*', got '%s'", + peek().line, + peek().text.c_str()); + } + + // ' [' is ambiguous: did the user mean " times the quadratic + // bracket" or "constant followed by a separate bracket"? Neither + // interpretation is supported. The LP convention places the coefficient + // inside the brackets, so reject and tell the user how to rewrite. + if (had_coeff && peek().kind == LpTokenKind::LBracket) { + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error at line %d: a numeric coefficient may not " + "directly precede a quadratic bracket '['; place the coefficient " + "inside the brackets", + peek().line); } if (peek().kind == LpTokenKind::Name && !at_section_boundary() && diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index 0b65c83c38..9e3443c2ce 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -1998,6 +1998,78 @@ End std::logic_error); } +TEST(lp_parser, leading_coefficient_before_objective_bracket_rejected) +{ + // '2 [ x^2 ] / 2' is ambiguous between "constant 2 plus 0.5 x^2" and + // "scalar 2 times 0.5 x^2"; the LP convention is to place coefficients + // inside the brackets, so reject. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + 2 [ x ^ 2 ] / 2 +Subject To + c1: x >= 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, leading_coefficient_before_constraint_bracket_rejected) +{ + // Same ambiguity as the objective case, in a quadratic constraint. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + q1: 2 [ x ^ 2 ] <= 5 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, constant_then_signed_bracket_in_objective_is_accepted) +{ + // The positive form: a literal constant in the objective followed by a + // signed quadratic bracket still parses (constant becomes objective offset). + auto m = parse_lp_string(R"LP( +Minimize + 5 + [ x ^ 2 ] / 2 +Subject To + c1: x >= 1 +End +)LP"); + EXPECT_NEAR(m.get_objective_offset(), 5.0, tolerance); + EXPECT_TRUE(m.has_quadratic_objective()); +} + +TEST(lp_parser, stray_star_after_number_without_variable_rejected) +{ + // '3 *' followed by a relation, section header, or EOL must error rather + // than silently drop the '*' and treat the '3' as a bare constant. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + 3 * +Subject To + c1: x >= 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, explicit_star_between_coefficient_and_variable_is_accepted) +{ + // The positive form: '3 * x' is the same as '3 x'. + auto m = parse_lp_string(R"LP( +Minimize + 3 * x +Subject To + c1: x >= 1 +End +)LP"); + int x = find_var(m, "x"); + ASSERT_GE(x, 0); + EXPECT_NEAR(m.get_objective_coefficients()[x], 3.0, tolerance); +} + // =========================================================================== // Quadratic constraints (LHS contains [ ... ] without the /2 divisor). // =========================================================================== From 7e3899c0679715b5621cebb9e48b0c92b02ded6f Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 18:38:34 +0000 Subject: [PATCH 04/18] Inline finalize_problem into lp_parser.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parser_finalize.hpp was designed as a shared finalization template but only the LP parser ever called it; MPS keeps its own fill_problem. Move the implementation into lp_parser.cpp's anonymous namespace alongside flush_quadratic_constraints, since both serve the same caller and run consecutively. While inlining, drop the dormant requires-expression branches for objective_scaling_factor_value, ranges_values, and qmatrix_entries — none of these fields exist on lp_parser_t. The Parser template parameter becomes a concrete lp_parser_t&. No behavior change for any caller. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/src/io/lp_parser.cpp | 177 ++++++++++++++++++++++- cpp/src/io/lp_parser.hpp | 7 +- cpp/src/io/parser_finalize.hpp | 255 --------------------------------- 3 files changed, 178 insertions(+), 261 deletions(-) delete mode 100644 cpp/src/io/parser_finalize.hpp diff --git a/cpp/src/io/lp_parser.cpp b/cpp/src/io/lp_parser.cpp index 91a1eec2d3..3dd4e3b45d 100644 --- a/cpp/src/io/lp_parser.cpp +++ b/cpp/src/io/lp_parser.cpp @@ -9,7 +9,6 @@ #include #include -#include #include #include @@ -1341,6 +1340,178 @@ void LpParseEngine::parse_all() namespace { +// 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 +// (mps_parser.cpp) because its quadratic rows are interleaved with linear +// rows in the per-row vectors and need a compaction pass; the LP parser +// partitions quadratic rows into quadratic_constraint_blocks at parse time, +// so the per-row vectors here contain only linear rows and no compaction is +// needed. Quadratic LP constraints are emitted by flush_quadratic_constraints +// below. +template +void finalize_problem(mps_data_model_t& problem, lp_parser_t& 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); + problem.set_objective_scaling_factor(f_t(1)); + 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 → 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); + 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]); + break; + case GreaterThanOrEqual: + clb.push_back(parser.b_values[i]); + cub.push_back(inf); + break; + case LesserThanOrEqual: + clb.push_back(-inf); + cub.push_back(parser.b_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); + 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 the full symmetric Q from upper-triangular + // QUADOBJ-convention entries; mirror off-diagonals and apply the file's + // '0.5 x^T Q x' → cuOpt's 'x^T Q x' conversion (×0.5 on every stored value). + if (!parser.quadobj_entries.empty()) { + std::vector>> csc(n_vars); + for (const auto& [row, col, val] : parser.quadobj_entries) { + csc[col].emplace_back(row, val); + if (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); + } +} + // 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 @@ -1376,7 +1547,7 @@ 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); + finalize_problem(problem, *this); flush_quadratic_constraints(problem, *this); } @@ -1384,7 +1555,7 @@ 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); + finalize_problem(problem, *this); flush_quadratic_constraints(problem, *this); } diff --git a/cpp/src/io/lp_parser.hpp b/cpp/src/io/lp_parser.hpp index b068f7535a..8314d6c97a 100644 --- a/cpp/src/io/lp_parser.hpp +++ b/cpp/src/io/lp_parser.hpp @@ -24,9 +24,10 @@ namespace cuopt::linear_programming::io { * 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. + * The public fields mirror mps_parser_t so tests and tools can introspect + * the same shape of intermediate data from either parser. Finalization + * (CSR flatten, constraint-bound derivation, quadratic objective assembly) + * is performed by finalize_problem() inside src/io/lp_parser.cpp. */ template class lp_parser_t { diff --git a/cpp/src/io/parser_finalize.hpp b/cpp/src/io/parser_finalize.hpp deleted file mode 100644 index 3c9a2ffbe1..0000000000 --- a/cpp/src/io/parser_finalize.hpp +++ /dev/null @@ -1,255 +0,0 @@ -/* 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 From f32d12e1730550019cbb37e0a6c164fef2e251be Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 18:47:13 +0000 Subject: [PATCH 05/18] Rewrite refactor-history comments to describe current state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two block-level comments were framed in terms of how this PR rearranged the code ("MPS-only tests preserved from mps_parser_test.cpp", "Extracted from ParseMps"). Replace with descriptions of what the code actually does today, independent of the refactor that produced it. The MPS-only test header also incorrectly claimed the LP parser doesn't support semi-continuous variables or quadratic constraints — it does; those tests are preserved because they exercise MPS-specific syntax (bound codes, QCMATRIX blocks), not because LP lacks the semantics. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/tests/linear_programming/parser_test.cpp | 5 ++--- .../cuopt/linear_programming/mps_parser/parser_wrapper.pyx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index 9e3443c2ce..7a2c2d1203 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -2541,9 +2541,8 @@ TEST(parse_problem, unrecognized_extension_throws) } // =========================================================================== -// 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). +// MPS-syntax-specific tests: bound codes (UP/LO/MI/PL/BV/SC) and QCMATRIX +// blocks. LP-equivalent semantic coverage lives above. // =========================================================================== TEST(mps_bounds, standard_var_bounds_0_inf) 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 9026750d47..6c392ac4bd 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx @@ -32,8 +32,8 @@ def type_cast(np_obj, np_type, name): # 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. +# Shared by ParseMps and ParseLp — 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() From 2b2ff753a688c8060d076311e455f26b88f29b8d Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 18:58:06 +0000 Subject: [PATCH 06/18] Accept bare 'Semi' and 'Semis' as Semi-Continuous section keywords The 3-token 'Semi - Continuous' form was the only spelling recognized, but both Gurobi and CPLEX LP-format references explicitly list bare 'Semi' and 'Semis' as documented synonyms: Gurobi: "Valid keywords for variable type headers are: binary, binaries, bin, general, generals, gen, semi-continuous, semis, or semi." CPLEX: "The SEMI-CONTINUOUS section is preceded by the keyword SEMI-CONTINUOUS, SEMI, or SEMIS." Accepted spellings (case-insensitive) are now: 'Semi-Continuous', 'Semi', and 'Semis'. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/src/io/lp_parser.cpp | 22 ++++++---- cpp/tests/linear_programming/parser_test.cpp | 44 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/cpp/src/io/lp_parser.cpp b/cpp/src/io/lp_parser.cpp index 3dd4e3b45d..54b27c8ebd 100644 --- a/cpp/src/io/lp_parser.cpp +++ b/cpp/src/io/lp_parser.cpp @@ -602,12 +602,13 @@ bool LpParseEngine::at_section_boundary() const 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; + // Semi-Continuous section header (supported); three spellings: + // - 3-token "Semi - Continuous" + // - bare "Semi" + // - bare "Semis" + // Plus other section headers that we recognize as boundaries (some + // supported, some unsupported — dispatch decides). + if (lower == "semi" || lower == "semis") return true; if (lower == "sos") return true; if (lower == "pwlobj") return true; if (lower == "scenarios" || lower == "scenario") return true; @@ -686,7 +687,10 @@ typename LpParseEngine::SectionKind LpParseEngine::try_consu advance(); return SectionKind::Binaries; } - // "Semi-Continuous" (3 tokens: semi - continuous). + // Semi-Continuous section header — accepted spellings: + // - 3-token "Semi - Continuous" (CPLEX/Gurobi documented) + // - bare "Semi" / "Semis" (CPLEX/Gurobi documented) + // Check the 3-token form first so it consumes all three tokens. if (lower == "semi" && peek(1).kind == LpTokenKind::Minus && name_equals_ci(peek(2), "continuous")) { advance(); @@ -694,6 +698,10 @@ typename LpParseEngine::SectionKind LpParseEngine::try_consu advance(); return SectionKind::SemiContinuous; } + if (lower == "semi" || lower == "semis") { + advance(); + return SectionKind::SemiContinuous; + } if (is_end_keyword(lower)) { advance(); return SectionKind::End; diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index 7a2c2d1203..f25eadf628 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -1748,6 +1748,50 @@ End EXPECT_NEAR(m.get_variable_upper_bounds()[xi], 10.0, tolerance); } +TEST(lp_parser, semi_continuous_bare_semi_keyword) +{ + // Both Gurobi and CPLEX accept the bare "Semi" keyword as a synonym for + // the "Semi-Continuous" section header. + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 0 +Bounds + 2 <= x <= 10 +Semi + x +End +)LP"); + int xi = find_var(m, "x"); + ASSERT_GE(xi, 0); + EXPECT_EQ(m.get_variable_types()[xi], 'S'); + 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_bare_semis_keyword) +{ + // Both Gurobi and CPLEX accept the bare "Semis" keyword as a synonym for + // the "Semi-Continuous" section header. + auto m = parse_lp_string(R"LP( +Minimize + x +Subject To + c1: x >= 0 +Bounds + 2 <= x <= 10 +Semis + x +End +)LP"); + int xi = find_var(m, "x"); + ASSERT_GE(xi, 0); + EXPECT_EQ(m.get_variable_types()[xi], 'S'); + 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( From 8c6f25a731c339b879d9261698edbfc2d9cd1052 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:02:18 +0000 Subject: [PATCH 07/18] Simplify LP parser's quadratic objective to upper-triangular pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cuOpt's set_quadratic_objective_matrix symmetrizes internally via H = Q + Q^T before solving (1/2) x^T H x, so the parser does not need to materialize the full symmetric Q. Switch the quadratic-objective path to: parse_quadratic_bracket: apply LP's '/ 2' uniformly to diagonal and off-diagonal entries (instead of only halving off-diagonals). finalize_problem: emit the upper-triangular quadobj_entries as CSR directly (drop the mirror pass and the post-scale by 0.5). End-to-end PDLP tests (cpp/tests/qp/unit_tests/lp_parser_solve_test.cu) parse three small QP files (diagonal, positive cross term, negative cross term) and verify objective values and primal solutions against hand-computed optima. Quadratic constraints are NOT changed: cuOpt's set_quadratic_constraints does not symmetrize, so per-constraint Q must remain fully symmetric. The MPS parser is also unchanged. LP and MPS now store the quadratic objective in different equivalent forms (upper-tri vs full-sym × 0.5) in mps_data_model_t; both produce the same H after cuOpt's symmetrize step. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/src/io/lp_parser.cpp | 46 +++---- cpp/tests/linear_programming/parser_test.cpp | 16 ++- cpp/tests/qp/CMakeLists.txt | 1 + .../qp/unit_tests/lp_parser_solve_test.cu | 121 ++++++++++++++++++ 4 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 cpp/tests/qp/unit_tests/lp_parser_solve_test.cu diff --git a/cpp/src/io/lp_parser.cpp b/cpp/src/io/lp_parser.cpp index 54b27c8ebd..cbafc6b45b 100644 --- a/cpp/src/io/lp_parser.cpp +++ b/cpp/src/io/lp_parser.cpp @@ -932,18 +932,14 @@ void LpParseEngine::parse_quadratic_bracket( 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 + // Apply the LP "/ 2" convention uniformly: a bracket coefficient c on + // either x_i^2 or x_i*x_j contributes c/2 to the corresponding objective + // term. The resulting upper-triangular quadobj entries are passed + // directly to cuOpt's set_quadratic_objective_matrix, which internally + // computes H = Q + Q^T; the solver then minimizes (1/2) x^T H x, which + // recovers the user's intended objective. 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); - } + 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. @@ -1487,31 +1483,27 @@ void finalize_problem(mps_data_model_t& problem, lp_parser_t problem.set_row_types(row_types_chars); problem.set_maximize(parser.maximize); - // Quadratic objective: build the full symmetric Q from upper-triangular - // QUADOBJ-convention entries; mirror off-diagonals and apply the file's - // '0.5 x^T Q x' → cuOpt's 'x^T Q x' conversion (×0.5 on every stored value). + // Quadratic objective: emit the upper-triangular quadobj entries as CSR. + // cuOpt's GPU-side set_quadratic_objective_matrix applies H = Q + Q^T + // internally, so no mirror step is needed here — the entries are already + // /2-scaled inside parse_quadratic_bracket so the solver's (1/2) x^T H x + // recovers the user's intended objective. if (!parser.quadobj_entries.empty()) { - std::vector>> csc(n_vars); + std::vector>> row_data(n_vars); for (const auto& [row, col, val] : parser.quadobj_entries) { - csc[col].emplace_back(row, val); - if (row != col) { csc[row].emplace_back(col, val); } + row_data[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); - } + for (auto& row : row_data) { + std::sort(row.begin(), row.end()); } - // 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.reserve(static_cast(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)); + for (const auto& [col, val] : row_data[row]) { + q_values.push_back(val); q_indices.push_back(col); } q_offsets.push_back(static_cast(q_values.size())); diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index f25eadf628..68fffcedfb 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -1621,16 +1621,18 @@ End 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. + // The LP parser stores Q in upper-triangular form (i <= j). cuOpt's + // set_quadratic_objective_matrix symmetrizes via H = Q + Q^T, and the + // solver minimizes (1/2) x^T H x. + // Diagonal 2 x^2 / 2 → Q[x,x] = 1. 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); + // Cross 2 x*y / 2 → stored as Q[x,y] = 1 only (no Q[y,x]). + EXPECT_NEAR(q_entry(m, x, y), 1.0, tolerance); + EXPECT_NEAR(q_entry(m, y, x), 0.0, tolerance); + EXPECT_NEAR(q_entry(m, y, z), 1.0, tolerance); + EXPECT_NEAR(q_entry(m, z, y), 0.0, tolerance); // x and z have no cross term. EXPECT_NEAR(q_entry(m, x, z), 0.0, tolerance); diff --git a/cpp/tests/qp/CMakeLists.txt b/cpp/tests/qp/CMakeLists.txt index e552987384..54cc3dc073 100644 --- a/cpp/tests/qp/CMakeLists.txt +++ b/cpp/tests/qp/CMakeLists.txt @@ -7,4 +7,5 @@ ConfigureTest(QP_UNIT_TEST ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/no_constraints.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/two_variable_test.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/mps_writer_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/lp_parser_solve_test.cu LABELS numopt) diff --git a/cpp/tests/qp/unit_tests/lp_parser_solve_test.cu b/cpp/tests/qp/unit_tests/lp_parser_solve_test.cu new file mode 100644 index 0000000000..3dc96acbdb --- /dev/null +++ b/cpp/tests/qp/unit_tests/lp_parser_solve_test.cu @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights + * reserved. SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include +#include + +namespace cuopt::linear_programming { + +// End-to-end smoke tests that parse an LP file and solve via PDLP. +// Validates objective value and primal solution against hand-computed +// optima. The point is to verify the LP parser's quadratic-objective +// representation (upper-triangular CSR) round-trips correctly through +// cuOpt's solver (which applies H = Q + Q^T internally before solving +// (1/2) x^T H x). + +namespace { + +void expect_optimal_solution(const std::string& lp_text, + double expected_objective, + const std::vector& expected_x) +{ + raft::handle_t handle; + auto problem = io::parse_lp_from_string(lp_text); + auto settings = pdlp_solver_settings_t(); + auto solution = solve_lp(&handle, problem, settings); + + ASSERT_EQ(solution.get_termination_status(), pdlp_termination_status_t::Optimal); + EXPECT_NEAR(solution.get_objective_value(), expected_objective, 1e-4); + + auto sol = cuopt::host_copy(solution.get_primal_solution(), handle.get_stream()); + ASSERT_EQ(sol.size(), expected_x.size()); + for (size_t i = 0; i < expected_x.size(); ++i) { + EXPECT_NEAR(sol[i], expected_x[i], 1e-4) << "x[" << i << "]"; + } +} + +} // namespace + +// Diagonal-only quadratic objective. +// Minimize x1^2 + 4 x2^2 - 8 x1 - 16 x2 s.t. x1 + x2 >= 5, 0 <= x1, x2 <= 10. +// Unconstrained optimum (4, 2) satisfies the constraint with slack; obj = -32. +TEST(lp_parser_solve, qp_diagonal_only) +{ + expect_optimal_solution(R"LP( +Minimize + obj: -8 x1 - 16 x2 + [ 2 x1 ^ 2 + 8 x2 ^ 2 ] / 2 +Subject To + c1: x1 + x2 >= 5 +Bounds + 0 <= x1 <= 10 + 0 <= x2 <= 10 +End +)LP", + -32.0, + {4.0, 2.0}); +} + +// Quadratic objective with a cross term — exercises the upper-triangular +// off-diagonal storage path that this PR introduced. +// +// Minimize x1^2 + 2 x1 x2 + 2 x2^2 - 6 x1 - 8 x2 s.t. x1 + x2 <= 10. +// Hessian H = [[2, 2], [2, 4]] is positive definite. +// Unconstrained optimum from KKT: (2, 1); obj = 4 + 4 + 2 - 12 - 8 = -10. +TEST(lp_parser_solve, qp_with_cross_term) +{ + expect_optimal_solution(R"LP( +Minimize + obj: -6 x1 - 8 x2 + [ 2 x1 ^ 2 + 4 x1 * x2 + 4 x2 ^ 2 ] / 2 +Subject To + c1: x1 + x2 <= 10 +Bounds + -100 <= x1 <= 100 + -100 <= x2 <= 100 +End +)LP", + -10.0, + {2.0, 1.0}); +} + +// Quadratic objective with a negative cross-term coefficient. This +// exercises the same upper-triangular off-diagonal storage path with a +// sign that gets carried through parse_quadratic_bracket via the per-term +// sign of `- 4 x1 * x2`. +// +// Minimize x1^2 - 2 x1 x2 + 2 x2^2 - 4 x1 s.t. x1 + x2 <= 100. +// Hessian H = [[2, -2], [-2, 4]] is positive definite. +// Unconstrained optimum from KKT: (4, 2); obj = 16 - 16 + 8 - 16 = -8. +TEST(lp_parser_solve, qp_with_negative_cross_term) +{ + expect_optimal_solution(R"LP( +Minimize + obj: -4 x1 + [ 2 x1 ^ 2 - 4 x1 * x2 + 4 x2 ^ 2 ] / 2 +Subject To + c1: x1 + x2 <= 100 +Bounds + -100 <= x1 <= 100 + -100 <= x2 <= 100 +End +)LP", + -8.0, + {4.0, 2.0}); +} + +} // namespace cuopt::linear_programming From 4268886e95fc01d3939a3b9f8b5e7d3acc6546eb Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:08:14 +0000 Subject: [PATCH 08/18] Reject bare linear terms inside quadratic '[ ... ]' brackets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Gurobi's LP-format reference, the bracket reserved for the quadratic portion of an objective or constraint expression accepts only squared terms (e.g., '2 x ^ 2') and product terms (e.g., '3 x * y'). HiGHS's LP reader (highs/io/filereaderlp/reader.cpp) likewise rejects anything other than those four forms. The parser previously accepted bare linear terms like '2 x' inside the bracket and folded them into the row's linear part — an extension not supported by any LP reader I could find documentation or source for. Tighten the parser to reject this and prune the now-dead out_linear plumbing from parse_quadratic_bracket and its two call sites. One existing test (qc_outer_minus_sign_flips_quadratic_and_linear) used the rejected form to verify outer-sign propagation. Rewrite it to use the conventional spelling with the linear term outside the bracket, and add two negative tests that pin the new rejection (one in objective, one in constraint). Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/src/io/lp_parser.cpp | 44 +++++++------------- cpp/tests/linear_programming/parser_test.cpp | 33 +++++++++++++-- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/cpp/src/io/lp_parser.cpp b/cpp/src/io/lp_parser.cpp index cbafc6b45b..1f46cf937d 100644 --- a/cpp/src/io/lp_parser.cpp +++ b/cpp/src/io/lp_parser.cpp @@ -262,8 +262,7 @@ class LpParseEngine { // 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, + void parse_quadratic_bracket(int outer_sign, BracketRole role, std::vector>& out_quad_entries); @@ -832,10 +831,7 @@ void LpParseEngine::parse_linear_expression(std::vector& o template void LpParseEngine::parse_quadratic_bracket( - std::vector& out_linear, - int outer_sign, - BracketRole role, - std::vector>& out_quad_entries) + int outer_sign, BracketRole role, std::vector>& out_quad_entries) { expect(LpTokenKind::LBracket, "'[' at start of quadratic section"); @@ -905,10 +901,16 @@ void LpParseEngine::parse_quadratic_bracket( 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}); + // Pure linear terms are not allowed inside a quadratic bracket — the + // LP convention reserves '[ ... ]' for squared and product terms only + // (matches Gurobi's documented LP format). Place linear terms outside + // the bracket. + mps_parser_expects(false, + error_type_t::ValidationError, + "LP parse error at line %d: bare linear term '%s' is not " + "allowed inside a quadratic bracket '[ ... ]'; move it outside", + peek().line, + var1.c_str()); } first = false; @@ -942,13 +944,6 @@ void LpParseEngine::parse_quadratic_bracket( 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. @@ -973,10 +968,6 @@ void LpParseEngine::parse_quadratic_bracket( 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; - } } } @@ -1009,11 +1000,7 @@ void LpParseEngine::parse_objective_section() 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); + parse_quadratic_bracket(quad_sign, BracketRole::Objective, out_.quadobj_entries); // More linear terms may follow the bracket. std::vector more; @@ -1069,10 +1056,7 @@ void LpParseEngine::parse_constraints_section() } 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); + parse_quadratic_bracket(quad_sign, BracketRole::Constraint, qc_triples); // More linear terms may follow the bracket. parse_linear_expression // does not produce a constant unless the user wrote one in the LHS; diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index 68fffcedfb..d7a5fc0197 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -2221,9 +2221,9 @@ End EXPECT_EQ(nth_qc(m, 1).constraint_row_name, "q2"); } -TEST(lp_parser, qc_outer_minus_sign_flips_quadratic_and_linear) +TEST(lp_parser, qc_outer_minus_sign_flips_quadratic) { - // `- [ x^2 + 2 x ] + 5` on the LHS contributes -x^2 - 2 x + 5 to the LHS. + // `- 2 x + 5 - [ x^2 ]` 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). @@ -2231,7 +2231,7 @@ TEST(lp_parser, qc_outer_minus_sign_flips_quadratic_and_linear) Minimize x Subject To - q1: - [ x ^ 2 + 2 x ] + 5 <= 10 + q1: - 2 x + 5 - [ x ^ 2 ] <= 10 Bounds x free End @@ -2245,6 +2245,33 @@ End EXPECT_NEAR(qc.linear_values[0], -2.0, tolerance); } +TEST(lp_parser, bare_linear_inside_objective_bracket_rejected) +{ + // Gurobi's LP-format docs reserve `[ ... ]` for quadratic terms only + // (squared and product). A bare linear term like `2 x` inside the + // bracket is malformed; the user should write it outside. + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + obj: [ x ^ 2 + 2 x ] / 2 +Subject To + c1: x >= 1 +End +)LP"), + std::logic_error); +} + +TEST(lp_parser, bare_linear_inside_constraint_bracket_rejected) +{ + EXPECT_THROW(parse_lp_string(R"LP( +Minimize + x +Subject To + q1: [ x ^ 2 + 2 x ] <= 5 +End +)LP"), + std::logic_error); +} + TEST(lp_parser, qc_named_constraint) { auto m = parse_lp_string(R"LP( From 9cfd53a06cce7e8016e17e2a3ef0dd0a1e21a8ab Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:16:49 +0000 Subject: [PATCH 09/18] Remove references to named commercial solvers from comments and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline source comments and public docstrings shouldn't single out specific solver vendors when describing LP-format conventions. Rephrase each spot to describe the rule itself instead — the conventions are the same whether or not a particular product is named. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/include/cuopt/linear_programming/io/parser.hpp | 6 +++--- cpp/src/io/lp_parser.cpp | 11 +++++------ cpp/tests/linear_programming/parser_test.cpp | 10 +++++----- .../cuopt/linear_programming/mps_parser/parser.py | 7 ++++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cpp/include/cuopt/linear_programming/io/parser.hpp b/cpp/include/cuopt/linear_programming/io/parser.hpp index 434efa5f97..a175e821cd 100644 --- a/cpp/include/cuopt/linear_programming/io/parser.hpp +++ b/cpp/include/cuopt/linear_programming/io/parser.hpp @@ -63,9 +63,9 @@ mps_data_model_t parse_mps_from_string(std::string_view mps_contents, * 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). + * supports the dialect in which the objective and constraints are written + * as algebraic expressions over named variables (it does not implement the + * alternative tableau-style LP dialect used by some open-source readers). * * Scope: LP, MIP, and QP problems are supported, plus semi-continuous * variables (via a Semi-Continuous section; finite upper bound required) diff --git a/cpp/src/io/lp_parser.cpp b/cpp/src/io/lp_parser.cpp index 1f46cf937d..84daa101b4 100644 --- a/cpp/src/io/lp_parser.cpp +++ b/cpp/src/io/lp_parser.cpp @@ -686,9 +686,9 @@ typename LpParseEngine::SectionKind LpParseEngine::try_consu advance(); return SectionKind::Binaries; } - // Semi-Continuous section header — accepted spellings: - // - 3-token "Semi - Continuous" (CPLEX/Gurobi documented) - // - bare "Semi" / "Semis" (CPLEX/Gurobi documented) + // Semi-Continuous section header. Documented spellings: + // - 3-token "Semi - Continuous" + // - bare "Semi" / "Semis" // Check the 3-token form first so it consumes all three tokens. if (lower == "semi" && peek(1).kind == LpTokenKind::Minus && name_equals_ci(peek(2), "continuous")) { @@ -902,9 +902,8 @@ void LpParseEngine::parse_quadratic_bracket( raw_quad.emplace_back(a, b, sign * coeff); } else { // Pure linear terms are not allowed inside a quadratic bracket — the - // LP convention reserves '[ ... ]' for squared and product terms only - // (matches Gurobi's documented LP format). Place linear terms outside - // the bracket. + // LP-format convention reserves '[ ... ]' for squared and product + // terms only. Place linear terms outside the bracket. mps_parser_expects(false, error_type_t::ValidationError, "LP parse error at line %d: bare linear term '%s' is not " diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index d7a5fc0197..541c71f23f 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -1752,8 +1752,8 @@ End TEST(lp_parser, semi_continuous_bare_semi_keyword) { - // Both Gurobi and CPLEX accept the bare "Semi" keyword as a synonym for - // the "Semi-Continuous" section header. + // The LP-format convention accepts the bare "Semi" keyword as a synonym + // for the "Semi-Continuous" section header. auto m = parse_lp_string(R"LP( Minimize x @@ -1774,8 +1774,8 @@ End TEST(lp_parser, semi_continuous_bare_semis_keyword) { - // Both Gurobi and CPLEX accept the bare "Semis" keyword as a synonym for - // the "Semi-Continuous" section header. + // The LP-format convention accepts the bare "Semis" keyword as a synonym + // for the "Semi-Continuous" section header. auto m = parse_lp_string(R"LP( Minimize x @@ -2247,7 +2247,7 @@ End TEST(lp_parser, bare_linear_inside_objective_bracket_rejected) { - // Gurobi's LP-format docs reserve `[ ... ]` for quadratic terms only + // The LP-format convention reserves `[ ... ]` for quadratic terms only // (squared and product). A bare linear term like `2 x` inside the // bracket is malformed; the user should write it outside. EXPECT_THROW(parse_lp_string(R"LP( diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/parser.py b/python/cuopt/cuopt/linear_programming/mps_parser/parser.py index 3107b106b2..8a56804955 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/parser.py +++ b/python/cuopt/cuopt/linear_programming/mps_parser/parser.py @@ -69,9 +69,10 @@ def ParseLp(lp_file_path): ``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). + This function parses the dialect in which the objective and constraints + are written as algebraic expressions over named variables (it does not + implement the alternative tableau-style LP dialect used by some + open-source readers). Unsupported LP sections (SOS, PWL objective, user cuts, general constraints) raise a ValueError. From a7cdfb8ed8238efc60326e5f7031c2230a588f96 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:35:39 +0000 Subject: [PATCH 10/18] cuopt_cli: expand filename --help to match runtime dispatch The argparse help string for the positional filename argument said only "input MPS or LP file (dispatched by .lp / .mps extension)", which both underspecified the supported set and omitted .qps and the compressed variants. Update it to enumerate every extension parse_problem() accepts. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/cuopt_cli.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cpp/cuopt_cli.cpp b/cpp/cuopt_cli.cpp index 5a325f2a0f..39aab47170 100644 --- a/cpp/cuopt_cli.cpp +++ b/cpp/cuopt_cli.cpp @@ -282,7 +282,10 @@ int main(int argc, char* argv[]) // Define all arguments with appropriate defaults and help messages program.add_argument("filename") - .help("input MPS or LP file (dispatched by .lp / .mps extension)") + .help( + "input problem file; format dispatched by extension (case-insensitive). " + "Supported: .lp, .mps, .qps and their .gz / .bz2 compressed variants " + "(e.g. .lp.gz, .mps.bz2, .qps.gz)") .nargs(1) .required(); From 94e730e36f4f436cb6e137148c2edc3d632dd8c4 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:41:59 +0000 Subject: [PATCH 11/18] c_api_tests: guarantee cleanup in read_lp_file_by_extension on failure The test wrote a temp .lp file and called cuOptReadProblem; cleanup (cuOptDestroyProblem and std::filesystem::remove) ran only at the end of the test body, so an assertion failure mid-test would leak both the problem handle and the temp file. Introduce a small RAII guard whose destructor unconditionally destroys the handle (if non-null) and removes the temp file, so cleanup runs on every exit path. std::filesystem::remove is called with std::error_code to avoid throwing during stack unwinding. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- .../c_api_tests/c_api_tests.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 2fc9bdbbb2..35d6c2dd1d 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 @@ -138,7 +138,20 @@ End } cuOptOptimizationProblem handle = nullptr; - cuopt_int_t status = cuOptReadProblem(lp_path.string().c_str(), &handle); + // Scope guard: tear the temp file and the problem handle down on every + // exit path (including assertion failure) so the test doesn't leak. + struct cleanup_t { + cuOptOptimizationProblem* handle_ptr; + const std::filesystem::path& lp_path; + ~cleanup_t() + { + if (*handle_ptr != nullptr) { cuOptDestroyProblem(handle_ptr); } + std::error_code ec; + std::filesystem::remove(lp_path, ec); + } + } cleanup{&handle, lp_path}; + + cuopt_int_t status = cuOptReadProblem(lp_path.string().c_str(), &handle); EXPECT_EQ(status, CUOPT_SUCCESS); ASSERT_NE(handle, nullptr); @@ -148,9 +161,6 @@ End 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); } From 079ed412612c8a432e989e56a633df20a9d55ab3 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:43:27 +0000 Subject: [PATCH 12/18] parser_test: exception-safe temp-file cleanup in dispatch_parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch_parse() writes content to a temp file, then calls parse_problem(tmp.string()), then removes the temp file. If parse_problem throws (the unrecognized-extension test exercises exactly this path), the std::filesystem::remove call is skipped and the file leaks. Replace the manual post-parse remove with an RAII guard whose destructor removes the file on every scope exit — success, return, or exception. std::error_code is passed to remove so the destructor never throws during stack unwinding while a parse exception is propagating. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/tests/linear_programming/parser_test.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index 541c71f23f..8e49439194 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -2499,9 +2499,18 @@ mps_data_model_t dispatch_parse(const std::string& content, const s std::ofstream out(tmp); out << content; } - auto model = parse_problem(tmp.string()); - std::filesystem::remove(tmp); - return model; + // Scope guard: remove the temp file even if parse_problem throws. + // std::error_code is passed so the destructor does not throw during stack + // unwinding when a parse exception is propagating. + struct cleanup_t { + const std::filesystem::path& path; + ~cleanup_t() + { + std::error_code ec; + std::filesystem::remove(path, ec); + } + } cleanup{tmp}; + return parse_problem(tmp.string()); } constexpr const char* kTrivialLp = R"LP( From bee7dfbe7d676b35b9cb84718db8c4c724330b39 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:46:52 +0000 Subject: [PATCH 13/18] Self-hosted client: extend extension dispatch to QPS and compressed variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two extension-routing blocks in cuopt_self_host_client.py only recognized literal '.lp' and '.mps' suffixes — '.qps' and the compressed variants (.lp.gz, .lp.bz2, .mps.gz, .mps.bz2, .qps.gz, .qps.bz2) fell through to the unparsed-upload path even though the underlying mps_parser.ParseMps / ParseLp accept all of them via the same C++ file_to_string dispatch as the CLI. Factor the extension check into a small helper that lowercases the path and strips a single .gz / .bz2 compression suffix before matching, then use it in both _parse_file_to_data_model (which picks ParseLp vs ParseMps) and read_cuopt_problem_data (which decides whether to parse client-side or ship to the server). QPS routes to ParseMps; that matches the C++ parse_problem() dispatch table. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cuopt_sh_client/cuopt_self_host_client.py | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) 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 ae59b84f5a..b13adeee74 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,6 +134,34 @@ def is_uuid(cuopt_problem_data): return False +# File extensions (case-insensitive, after stripping a compression suffix) that +# the cuopt mps_parser package can parse client-side. Matches the dispatch +# table in parse_problem() on the C++ side. +_PARSEABLE_LP_EXTS = (".lp",) +_PARSEABLE_MPS_EXTS = (".mps", ".qps") +_COMPRESSION_SUFFIXES = (".gz", ".bz2") + + +def _strip_compression_suffix(lowered_path): + for suffix in _COMPRESSION_SUFFIXES: + if lowered_path.endswith(suffix): + return lowered_path[: -len(suffix)] + return lowered_path + + +def _client_parseable_extension(path): + """Return 'lp', 'mps', or None for a path. + + Case-insensitive; recognizes .gz / .bz2 compressed variants. + """ + base = _strip_compression_suffix(path.lower()) + if base.endswith(_PARSEABLE_LP_EXTS): + return "lp" + if base.endswith(_PARSEABLE_MPS_EXTS): + return "mps" + return None + + def _parse_file_to_data_model(problem_input, solver_config): try: from cuopt.linear_programming import mps_parser @@ -145,19 +173,24 @@ def _parse_file_to_data_model(problem_input, solver_config): "or pass an already-parsed dict instead of an MPS/LP file or " "DataModel." ) from e - # problem_input is either a path (str) to an MPS/LP file, or an - # mps_parser DataModel already handed to us. + # problem_input is either a path (str) to an MPS/LP/QPS file (optionally + # .gz / .bz2 compressed), 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() - # Dispatch on file extension: ".lp" ⇒ LP parser, otherwise MPS. - if isinstance(problem_input, str) and problem_input.lower().endswith( - ".lp" - ): + kind = ( + _client_parseable_extension(problem_input) + if isinstance(problem_input, str) + else None + ) + if kind == "lp": model = mps_parser.ParseLp(problem_input) else: + # MPS, QPS, and any unrecognized extension fall through to the + # MPS parser, which accepts both .mps and .qps (and their .gz / + # .bz2 variants) via the underlying C++ parse_mps(). model = mps_parser.ParseMps(problem_input) parse_time = time.time() - t0 log.debug(f"file parsing time was {parse_time}") @@ -809,13 +842,15 @@ def read_cuopt_problem_data(cuopt_data_model, filepath): needs_parsing = False filepath = False else: - # Needs parsing if it's either (a) a string path ending in - # .mps/.lp, or (b) a non-string (DataModel) to normalize. + # Needs parsing if it's either (a) a string path with a + # client-parseable extension (.lp / .mps / .qps, optionally + # .gz / .bz2 compressed), 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") + needs_parsing = ( + _client_parseable_extension(cuopt_data_model) + is not None + ) else: needs_parsing = True From 9a915329c6d3a7b5843f0a033c8267232fec5281 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Sun, 17 May 2026 23:59:30 +0000 Subject: [PATCH 14/18] Rename Python mps_parser package to io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cuopt.linear_programming.mps_parser package now exposes both ParseMps and ParseLp, so 'mps_parser' is no longer accurate. Rename the directory to 'io' (mirroring the cpp/include/cuopt/linear_programming/io/ layout on the C++ side) and rename the decorator catch_mps_parser_exception → catch_io_exception. This package was added on this branch in 72ba0540 (post-26.04) and has not appeared in any release, so the rename is safe without a compatibility shim. Importing files that previously did 'from cuopt.linear_programming import mps_parser' now import 'io as mps_parser' to keep the local binding stable; user-facing docs/examples and the public top-level import in cuopt/linear_programming/__init__.py use the new module name directly. The C++ error tag MPS_PARSER_ERROR_TYPE that the decorator parses out of RuntimeError messages is intentionally unchanged — that tag is produced by the mps_parser_expects() macro, which is shared between both parsers on the C++ side and would have a much larger blast radius to rename. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/lp/examples/mps_datamodel_example.py | 6 +++--- docs/cuopt/source/hidden/mps-api.rst | 4 ++-- docs/cuopt/source/hidden/mps-example.rst | 4 ++-- python/cuopt/cuopt/CMakeLists.txt | 2 +- python/cuopt/cuopt/linear_programming/__init__.py | 2 +- .../{mps_parser => io}/CMakeLists.txt | 0 .../{mps_parser => io}/__init__.py | 2 +- .../linear_programming/{mps_parser => io}/parser.pxd | 0 .../linear_programming/{mps_parser => io}/parser.py | 12 ++++++------ .../{mps_parser => io}/parser_wrapper.pyx | 8 ++++---- .../{mps_parser => io}/utilities/__init__.py | 4 ++-- .../utilities/exception_handler.py | 11 +++++++++-- python/cuopt/cuopt/linear_programming/problem.py | 2 +- .../cuopt/cuopt/linear_programming/solver/solver.py | 4 ++-- .../linear_programming/test_cpu_only_execution.py | 10 +++++----- .../linear_programming/test_incumbent_callbacks.py | 2 +- .../cuopt/tests/linear_programming/test_lp_solver.py | 2 +- .../cuopt/tests/linear_programming/test_parser.py | 4 ++-- .../cuopt_sh_client/cuopt_self_host_client.py | 12 ++++++------ .../cuopt_server/tests/test_pdlp_warmstart.py | 2 +- 20 files changed, 50 insertions(+), 43 deletions(-) rename python/cuopt/cuopt/linear_programming/{mps_parser => io}/CMakeLists.txt (100%) rename python/cuopt/cuopt/linear_programming/{mps_parser => io}/__init__.py (63%) rename python/cuopt/cuopt/linear_programming/{mps_parser => io}/parser.pxd (100%) rename python/cuopt/cuopt/linear_programming/{mps_parser => io}/parser.py (95%) rename python/cuopt/cuopt/linear_programming/{mps_parser => io}/parser_wrapper.pyx (97%) rename python/cuopt/cuopt/linear_programming/{mps_parser => io}/utilities/__init__.py (66%) rename python/cuopt/cuopt/linear_programming/{mps_parser => io}/utilities/exception_handler.py (68%) diff --git a/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py b/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py index e6ff6add73..04be2e6987 100644 --- a/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py +++ b/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py @@ -4,7 +4,7 @@ LP DataModel from MPS Parser Example This example demonstrates how to: -- Parse an MPS file using cuopt.linear_programming.mps_parser +- Parse an MPS file using cuopt.linear_programming.io - Create a DataModel from the parsed MPS - Solve using the DataModel via the server - Extract detailed solution information @@ -32,7 +32,7 @@ ThinClientSolverSettings, PDLPSolverMode, ) -from cuopt.linear_programming import mps_parser +from cuopt.linear_programming import ParseMps import time @@ -65,7 +65,7 @@ def main(): # Parse the MPS file and measure the time spent print("\n=== Parsing MPS File ===") parse_start = time.time() - data_model = mps_parser.ParseMps(data) + data_model = ParseMps(data) parse_time = time.time() - parse_start print(f"Parse time: {parse_time:.3f} seconds") diff --git a/docs/cuopt/source/hidden/mps-api.rst b/docs/cuopt/source/hidden/mps-api.rst index 362a26a51a..637d8a03cc 100644 --- a/docs/cuopt/source/hidden/mps-api.rst +++ b/docs/cuopt/source/hidden/mps-api.rst @@ -5,9 +5,9 @@ cuOpt MPS/LP Parser API Reference MPS Parser ---------- -.. autofunction:: cuopt.linear_programming.mps_parser.ParseMps +.. autofunction:: cuopt.linear_programming.io.ParseMps LP Parser --------- -.. autofunction:: cuopt.linear_programming.mps_parser.ParseLp +.. autofunction:: cuopt.linear_programming.io.ParseLp diff --git a/docs/cuopt/source/hidden/mps-example.rst b/docs/cuopt/source/hidden/mps-example.rst index 7ceae8aa21..cc6495d2b4 100644 --- a/docs/cuopt/source/hidden/mps-example.rst +++ b/docs/cuopt/source/hidden/mps-example.rst @@ -9,5 +9,5 @@ Example .. code-block:: python :linenos: - from cuopt.linear_programming import mps_parser - x = mps_parser.ParseMps('good-mps-1.mps') + from cuopt.linear_programming import ParseMps + x = ParseMps('good-mps-1.mps') diff --git a/python/cuopt/cuopt/CMakeLists.txt b/python/cuopt/cuopt/CMakeLists.txt index 996f1b1953..ba5eb25ddf 100644 --- a/python/cuopt/cuopt/CMakeLists.txt +++ b/python/cuopt/cuopt/CMakeLists.txt @@ -5,7 +5,7 @@ add_subdirectory(distance_engine) add_subdirectory(linear_programming/data_model) -add_subdirectory(linear_programming/mps_parser) +add_subdirectory(linear_programming/io) add_subdirectory(linear_programming/solver) add_subdirectory(routing) diff --git a/python/cuopt/cuopt/linear_programming/__init__.py b/python/cuopt/cuopt/linear_programming/__init__.py index d171b12878..6950d72bc8 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 ParseLp, ParseMps +from cuopt.linear_programming.io 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/CMakeLists.txt b/python/cuopt/cuopt/linear_programming/io/CMakeLists.txt similarity index 100% rename from python/cuopt/cuopt/linear_programming/mps_parser/CMakeLists.txt rename to python/cuopt/cuopt/linear_programming/io/CMakeLists.txt diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/__init__.py b/python/cuopt/cuopt/linear_programming/io/__init__.py similarity index 63% rename from python/cuopt/cuopt/linear_programming/mps_parser/__init__.py rename to python/cuopt/cuopt/linear_programming/io/__init__.py index ded8d5a06c..f81e9369ec 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/__init__.py +++ b/python/cuopt/cuopt/linear_programming/io/__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 ParseLp, ParseMps, toDict +from cuopt.linear_programming.io.parser import ParseLp, ParseMps, toDict diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/parser.pxd b/python/cuopt/cuopt/linear_programming/io/parser.pxd similarity index 100% rename from python/cuopt/cuopt/linear_programming/mps_parser/parser.pxd rename to python/cuopt/cuopt/linear_programming/io/parser.pxd diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/parser.py b/python/cuopt/cuopt/linear_programming/io/parser.py similarity index 95% rename from python/cuopt/cuopt/linear_programming/mps_parser/parser.py rename to python/cuopt/cuopt/linear_programming/io/parser.py index 8a56804955..9cec1556f6 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/parser.py +++ b/python/cuopt/cuopt/linear_programming/io/parser.py @@ -2,13 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 import numpy as np -from cuopt.linear_programming.mps_parser import parser_wrapper -from cuopt.linear_programming.mps_parser.utilities import ( - catch_mps_parser_exception, +from cuopt.linear_programming.io import parser_wrapper +from cuopt.linear_programming.io.utilities import ( + catch_io_exception, ) -@catch_mps_parser_exception +@catch_io_exception def ParseMps(mps_file_path, fixed_mps_format=False): """ Reads the equation from the input text file which is MPS formatted @@ -54,7 +54,7 @@ def ParseMps(mps_file_path, fixed_mps_format=False): return parser_wrapper.ParseMps(mps_file_path, fixed_mps_format) -@catch_mps_parser_exception +@catch_io_exception def ParseLp(lp_file_path): """ Reads an optimization problem from a file in LP format. @@ -102,7 +102,7 @@ def ParseLp(lp_file_path): def toDict(model, json=False): if not isinstance(model, parser_wrapper.DataModel): raise ValueError( - "model must be a cuopt.linear_programming.mps_parser.parser_wrapper.DataModel" + "model must be a cuopt.linear_programming.io.parser_wrapper.DataModel" ) # Replace numpy objects in generated data so that it is JSON serializable diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx b/python/cuopt/cuopt/linear_programming/io/parser_wrapper.pyx similarity index 97% rename from python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx rename to python/cuopt/cuopt/linear_programming/io/parser_wrapper.pyx index 6c392ac4bd..61e10b1864 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/parser_wrapper.pyx +++ b/python/cuopt/cuopt/linear_programming/io/parser_wrapper.pyx @@ -7,8 +7,8 @@ # cython: embedsignature = True # cython: language_level = 3 -from cuopt.linear_programming.mps_parser.utilities import ( - catch_mps_parser_exception, +from cuopt.linear_programming.io.utilities import ( + catch_io_exception, ) from libc.stdint cimport uintptr_t @@ -138,7 +138,7 @@ cdef _marshal_data_model(mps_data_model_t[int, double]* dm, data_model): return data_model -@catch_mps_parser_exception +@catch_io_exception def ParseMps(mps_file_path, fixed_mps_formats): data_model = DataModel() dm_ret_ptr = move( @@ -150,7 +150,7 @@ def ParseMps(mps_file_path, fixed_mps_formats): return _marshal_data_model(dm_ret_ptr.get(), data_model) -@catch_mps_parser_exception +@catch_io_exception def ParseLp(lp_file_path): data_model = DataModel() dm_ret_ptr = move( diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/utilities/__init__.py b/python/cuopt/cuopt/linear_programming/io/utilities/__init__.py similarity index 66% rename from python/cuopt/cuopt/linear_programming/mps_parser/utilities/__init__.py rename to python/cuopt/cuopt/linear_programming/io/utilities/__init__.py index e782831bac..23edf306ed 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/utilities/__init__.py +++ b/python/cuopt/cuopt/linear_programming/io/utilities/__init__.py @@ -1,9 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from cuopt.linear_programming.mps_parser.utilities.exception_handler import ( +from cuopt.linear_programming.io.utilities.exception_handler import ( InputRuntimeError, InputValidationError, OutOfMemoryError, - catch_mps_parser_exception, + catch_io_exception, ) diff --git a/python/cuopt/cuopt/linear_programming/mps_parser/utilities/exception_handler.py b/python/cuopt/cuopt/linear_programming/io/utilities/exception_handler.py similarity index 68% rename from python/cuopt/cuopt/linear_programming/mps_parser/utilities/exception_handler.py rename to python/cuopt/cuopt/linear_programming/io/utilities/exception_handler.py index 55041e6cfa..c6e4c6990b 100644 --- a/python/cuopt/cuopt/linear_programming/mps_parser/utilities/exception_handler.py +++ b/python/cuopt/cuopt/linear_programming/io/utilities/exception_handler.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import functools @@ -17,7 +17,14 @@ class OutOfMemoryError(Exception): pass -def catch_mps_parser_exception(f): +def catch_io_exception(f): + """Translate the C++ parser's JSON-tagged RuntimeError to a typed Python + exception. The error tag string ("MPS_PARSER_ERROR_TYPE") is preserved + verbatim because it's produced by the C++ mps_parser_expects() macro, + which is shared between the MPS and LP parsers; renaming it would be a + C++-side change with a larger blast radius. + """ + @functools.wraps(f) def func(*args, **kwargs): try: diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index f7f874fafd..0f4c6c3846 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -9,7 +9,7 @@ from scipy.sparse import coo_matrix import cuopt.linear_programming.data_model as data_model -import cuopt.linear_programming.mps_parser as mps_parser +import cuopt.linear_programming.io as mps_parser import cuopt.linear_programming.solver as solver import cuopt.linear_programming.solver_settings as solver_settings import warnings diff --git a/python/cuopt/cuopt/linear_programming/solver/solver.py b/python/cuopt/cuopt/linear_programming/solver/solver.py index 3dd5af35c9..3c72956742 100644 --- a/python/cuopt/cuopt/linear_programming/solver/solver.py +++ b/python/cuopt/cuopt/linear_programming/solver/solver.py @@ -149,11 +149,11 @@ def BatchSolve(data_model_list, solver_settings=None): >>> from cuopt import linear_programming >>> from cuopt.linear_programming.solver_settings import PDLPSolverMode >>> from cuopt.linear_programming.solver.solver_parameters import * - >>> from cuopt.linear_programming import mps_parser + >>> from cuopt.linear_programming import ParseMps >>> >>> data_models = [] >>> for i in range(...): - >>> data_models.append(mps_parser.ParseMps(...)) + >>> data_models.append(ParseMps(...)) >>> >>> # Build a solver setting object >>> settings = linear_programming.SolverSettings() diff --git a/python/cuopt/cuopt/tests/linear_programming/test_cpu_only_execution.py b/python/cuopt/cuopt/tests/linear_programming/test_cpu_only_execution.py index bb84599aa5..1c3a83b162 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_cpu_only_execution.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_cpu_only_execution.py @@ -23,7 +23,7 @@ import sys import time -from cuopt.linear_programming import mps_parser +from cuopt.linear_programming import io as mps_parser import pytest from cuopt import linear_programming from cuopt.linear_programming.solver.solver_parameters import CUOPT_TIME_LIMIT @@ -301,7 +301,7 @@ def _run_in_subprocess(func, env=None, timeout=120): def _impl_lp_solve_cpu_only(): """LP solve returns correctly-sized solution vectors.""" from cuopt import linear_programming - from cuopt.linear_programming import mps_parser + from cuopt.linear_programming import io as mps_parser dataset_root = os.environ.get("RAPIDS_DATASET_ROOT_DIR", "./") mps_file = f"{dataset_root}/linear_programming/afiro_original.mps" @@ -331,7 +331,7 @@ def _impl_lp_solve_cpu_only(): def _impl_lp_dual_solution_cpu_only(): """Dual solution and reduced costs are correctly sized.""" from cuopt import linear_programming - from cuopt.linear_programming import mps_parser + from cuopt.linear_programming import io as mps_parser dataset_root = os.environ.get("RAPIDS_DATASET_ROOT_DIR", "./") mps_file = f"{dataset_root}/linear_programming/afiro_original.mps" @@ -364,7 +364,7 @@ def _impl_mip_solve_cpu_only(): from cuopt.linear_programming.solver.solver_parameters import ( CUOPT_TIME_LIMIT, ) - from cuopt.linear_programming import mps_parser + from cuopt.linear_programming import io as mps_parser dataset_root = os.environ.get("RAPIDS_DATASET_ROOT_DIR", "./") mps_file = f"{dataset_root}/mip/bb_optimality.mps" @@ -400,7 +400,7 @@ def _impl_warmstart_cpu_only(): CUOPT_PRESOLVE, ) from cuopt.linear_programming.solver_settings import SolverMethod - from cuopt.linear_programming import mps_parser + from cuopt.linear_programming import io as mps_parser dataset_root = os.environ.get("RAPIDS_DATASET_ROOT_DIR", "./") mps_file = f"{dataset_root}/linear_programming/afiro_original.mps" diff --git a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py index 55a34016bd..20b0b0cca6 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_incumbent_callbacks.py @@ -3,7 +3,7 @@ import os -from cuopt.linear_programming import mps_parser +from cuopt.linear_programming import io as mps_parser import pytest from cuopt.linear_programming import solver, solver_settings diff --git a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py index feb5b4ad5e..a3eab8d98e 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py @@ -3,7 +3,7 @@ import os -from cuopt.linear_programming import mps_parser +from cuopt.linear_programming import io as mps_parser import numpy as np import pytest diff --git a/python/cuopt/cuopt/tests/linear_programming/test_parser.py b/python/cuopt/cuopt/tests/linear_programming/test_parser.py index f1b569a092..795f1fe298 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_parser.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_parser.py @@ -4,10 +4,10 @@ import os import tempfile -from cuopt.linear_programming import mps_parser +from cuopt.linear_programming import io as mps_parser import numpy as np import pytest -from cuopt.linear_programming.mps_parser.utilities import InputValidationError +from cuopt.linear_programming.io.utilities import InputValidationError RAPIDS_DATASET_ROOT_DIR = os.getenv("RAPIDS_DATASET_ROOT_DIR") if RAPIDS_DATASET_ROOT_DIR is None: 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 b13adeee74..a5b76d57f3 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 @@ -135,8 +135,8 @@ def is_uuid(cuopt_problem_data): # File extensions (case-insensitive, after stripping a compression suffix) that -# the cuopt mps_parser package can parse client-side. Matches the dispatch -# table in parse_problem() on the C++ side. +# the cuopt.linear_programming.io package can parse client-side. Matches the +# dispatch table in parse_problem() on the C++ side. _PARSEABLE_LP_EXTS = (".lp",) _PARSEABLE_MPS_EXTS = (".mps", ".qps") _COMPRESSION_SUFFIXES = (".gz", ".bz2") @@ -164,7 +164,7 @@ def _client_parseable_extension(path): def _parse_file_to_data_model(problem_input, solver_config): try: - from cuopt.linear_programming import mps_parser + from cuopt.linear_programming import io as mps_parser except ImportError as e: raise ImportError( "MPS/LP parsing on the client requires the cuopt package. " @@ -774,14 +774,14 @@ def get_LP_solve( Note - Batch mode is only supported in LP and not in MILP 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]. + cuopt.linear_programming.io/list[mps or lp file paths]/list[dict]/list[DataModel]. 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/ + /DataModel returned by cuopt.linear_programming.io/ path to json file/ dictionary. 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 + files/ a list of DataModel returned by cuopt.linear_programming.io/ a list of dictionaries. To use a cached cuopt problem data, input should be a uuid diff --git a/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py b/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py index 06c5df843d..76dd22c054 100644 --- a/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py +++ b/python/cuopt_server/cuopt_server/tests/test_pdlp_warmstart.py @@ -3,7 +3,7 @@ import os -from cuopt.linear_programming import mps_parser +from cuopt.linear_programming import io as mps_parser import msgpack from cuopt.linear_programming import solver_settings From 1683bea8c6110841b12e4fd01048020378ad5d4e Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Mon, 18 May 2026 00:09:02 +0000 Subject: [PATCH 15/18] ParseLp: add type annotations and Raises docstring section Annotate ParseLp with `lp_file_path: str` and `-> DataModel`, importing DataModel from cuopt.linear_programming.data_model so the annotation resolves at runtime. The old docstring claimed unsupported-section input raises ValueError; in practice the catch_io_exception decorator translates the C++ parser's tagged RuntimeError into typed Python exceptions (InputValidationError / InputRuntimeError / OutOfMemoryError). Replace the misleading sentence with a proper Raises section listing all three typed exceptions and the conditions that trigger them. Also expand the lp_file_path parameter description to mention the compressed-input variants the parser accepts, and trim the Returns phrasing so it points at lp_file_path explicitly. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cuopt/linear_programming/io/parser.py | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/io/parser.py b/python/cuopt/cuopt/linear_programming/io/parser.py index 9cec1556f6..385edfefe1 100644 --- a/python/cuopt/cuopt/linear_programming/io/parser.py +++ b/python/cuopt/cuopt/linear_programming/io/parser.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import numpy as np +from cuopt.linear_programming.data_model import DataModel from cuopt.linear_programming.io import parser_wrapper from cuopt.linear_programming.io.utilities import ( catch_io_exception, @@ -55,9 +56,8 @@ def ParseMps(mps_file_path, fixed_mps_format=False): @catch_io_exception -def ParseLp(lp_file_path): - """ - Reads an optimization problem from a file in LP format. +def ParseLp(lp_file_path: str) -> DataModel: + """Read 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 @@ -67,25 +67,47 @@ def ParseLp(lp_file_path): 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``). + by ``/ 2`` (coefficients are at face value, ``x^T Q x``). Only squared + (``x^2``) and product (``x * y``) terms are allowed inside the + bracket; bare linear terms must be written outside it. This function parses the dialect in which the objective and constraints are written as algebraic expressions over named variables (it does not implement the alternative tableau-style LP dialect used by some open-source readers). - Unsupported LP sections (SOS, PWL objective, user cuts, general - constraints) raise a ValueError. - Parameters ---------- lp_file_path : str - Path to LP-formatted file. + Path to LP-formatted file. May end in ``.lp``, ``.lp.gz``, or + ``.lp.bz2``; compressed inputs are decompressed at read time + via zlib / libbz2 when those libraries are available. Returns ------- - data_model: DataModel - A fully formed LP/MIP/QP problem representing the given file. + data_model : DataModel + A fully formed LP/MIP/QP problem representing the contents of + ``lp_file_path``. + + Raises + ------ + InputValidationError + Raised when ``lp_file_path`` is malformed or uses unsupported + syntax. Examples include unsupported sections (SOS, PWL + objective, user cuts, general constraints), bare linear terms + inside a quadratic ``[ ... ]`` bracket, an objective bracket + not followed by ``/ 2``, a constraint bracket followed by + ``/ 2``, a semi-continuous variable without a finite upper + bound, and similar input-level errors raised by the underlying + C++ parser. Exceptions propagated from + :func:`parser_wrapper.ParseLp` are translated to this type by + :func:`catch_io_exception`. + InputRuntimeError + Raised for non-validation runtime errors that the C++ parser + flags during file I/O or parsing. + OutOfMemoryError + Raised when the parser cannot allocate memory for the + resulting data model. Examples -------- @@ -95,7 +117,6 @@ def ParseLp(lp_file_path): >>> solver_settings = linear_programming.SolverSettings() >>> solution = linear_programming.Solve(data_model, solver_settings) """ - return parser_wrapper.ParseLp(lp_file_path) From c82cfccb0a17bafd6f9e5c1d31dc2f3146a5bc2e Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Mon, 18 May 2026 00:16:28 +0000 Subject: [PATCH 16/18] parser_test: replace shared /tmp paths with RAII temp_file_t The mps_roundtrip and lp_roundtrip tests each used a fixed shared /tmp path (e.g. /tmp/mps_roundtrip_lp_test.mps) and removed it manually at the end of the test. Two problems: 1. Parallel test runs writing to the same path race each other. 2. An assertion failure or exception between write and remove leaks the file. Add a small temp_file_t RAII helper that picks a unique path under std::filesystem::temp_directory_path() using pid + an atomic counter, and removes the file on every scope exit (std::error_code so the destructor never throws). Adopt it in every test that previously spelled a literal /tmp/*.mps path (the six mps/lp roundtrip tests and the QCQP-QC1 roundtrip), and use it inside dispatch_parse too, replacing the existing inline cleanup_t scope guard. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/tests/linear_programming/parser_test.cpp | 124 +++++++++---------- 1 file changed, 59 insertions(+), 65 deletions(-) diff --git a/cpp/tests/linear_programming/parser_test.cpp b/cpp/tests/linear_programming/parser_test.cpp index 8e49439194..4263a594ce 100644 --- a/cpp/tests/linear_programming/parser_test.cpp +++ b/cpp/tests/linear_programming/parser_test.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -25,6 +26,7 @@ #include #include #include +#include #include namespace cuopt::linear_programming::io { @@ -1039,6 +1041,31 @@ TEST(qps_parser, test_qps_files) // MPS Round-Trip Tests (Read -> Write -> Read -> Compare) // ================================================================================================ +// RAII temp file path: builds a unique path under temp_directory_path() and +// removes it on scope exit, so write/parse/compare can throw without leaking +// the file and parallel test runs don't collide on a shared name. The file +// is not created at construction; it appears when the writer writes to +// `path()`. +struct temp_file_t { + std::filesystem::path p; + explicit temp_file_t(const std::string& suffix) + { + static std::atomic counter{0}; + const auto pid = static_cast(::getpid()); + const auto n = counter.fetch_add(1, std::memory_order_relaxed); + p = std::filesystem::temp_directory_path() / + ("cuopt_test_" + std::to_string(pid) + "_" + std::to_string(n) + suffix); + } + ~temp_file_t() + { + std::error_code ec; + std::filesystem::remove(p, ec); + } + temp_file_t(const temp_file_t&) = delete; + temp_file_t& operator=(const temp_file_t&) = delete; + std::string string() const { return p.string(); } +}; + // Helper function to compare two data models template void compare_data_models(const mps_data_model_t& original, @@ -1164,23 +1191,20 @@ 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"; + temp_file_t temp_file(".mps"); // Read original auto original = parse_mps(input_file, true); // Write to temp file mps_writer_t writer(original); - writer.write(temp_file); + writer.write(temp_file.string()); // Read back - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), false); // Compare compare_data_models(original, reloaded); - - // Cleanup - std::filesystem::remove(temp_file); } TEST(mps_roundtrip, linear_programming_with_bounds) @@ -1191,23 +1215,20 @@ TEST(mps_roundtrip, linear_programming_with_bounds) 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"; + temp_file_t temp_file(".mps"); // Read original auto original = parse_mps(input_file, false); // Write to temp file mps_writer_t writer(original); - writer.write(temp_file); + writer.write(temp_file.string()); // Read back - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), false); // Compare compare_data_models(original, reloaded); - - // Cleanup - std::filesystem::remove(temp_file); } TEST(mps_roundtrip, quadratic_programming_qp_test_1) @@ -1218,7 +1239,7 @@ TEST(mps_roundtrip, quadratic_programming_qp_test_1) 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"; + temp_file_t temp_file(".mps"); // Read original auto original = parse_mps(input_file, false); @@ -1226,17 +1247,14 @@ TEST(mps_roundtrip, quadratic_programming_qp_test_1) // Write to temp file mps_writer_t writer(original); - writer.write(temp_file); + writer.write(temp_file.string()); // Read back - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), 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) @@ -1247,7 +1265,7 @@ TEST(mps_roundtrip, quadratic_programming_qp_test_2) 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"; + temp_file_t temp_file(".mps"); // Read original auto original = parse_mps(input_file, false); @@ -1255,17 +1273,14 @@ TEST(mps_roundtrip, quadratic_programming_qp_test_2) // Write to temp file mps_writer_t writer(original); - writer.write(temp_file); + writer.write(temp_file.string()); // Read back - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), false); ASSERT_TRUE(reloaded.has_quadratic_objective()) << "Reloaded should have quadratic objective"; // Compare compare_data_models(original, reloaded); - - // Cleanup - std::filesystem::remove(temp_file); } // ================================================================================================ @@ -1278,50 +1293,44 @@ TEST(mps_roundtrip, quadratic_programming_qp_test_2) TEST_F(good_mps_1_test, lp_roundtrip) { - std::string temp_file = "/tmp/lp_roundtrip_lp_basic.mps"; + temp_file_t temp_file(".mps"); auto original = parse_lp_file("linear_programming/good-mps-1.lp"); mps_writer_t writer(original); - writer.write(temp_file); + writer.write(temp_file.string()); - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), 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"; + temp_file_t temp_file(".mps"); auto original = parse_lp_file("linear_programming/lp_model_with_var_bounds.lp"); mps_writer_t writer(original); - writer.write(temp_file); + writer.write(temp_file.string()); - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), 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"; + temp_file_t temp_file(".mps"); auto original = parse_lp_file("mixed_integer_programming/good-mip-mps-1.lp"); mps_writer_t writer(original); - writer.write(temp_file); + writer.write(temp_file.string()); - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), false); compare_data_models(original, reloaded); - - std::filesystem::remove(temp_file); } // ================================================================================================ @@ -2489,27 +2498,15 @@ End namespace { // Writes `content` to a temp file with the given suffix, parses it via -// parse_problem, removes the file, and returns the resulting model. +// parse_problem, and returns the resulting model. temp_file_t removes the +// file on every scope exit (including when parse_problem throws). 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); + temp_file_t tmp(suffix); { - std::ofstream out(tmp); + std::ofstream out(tmp.string()); out << content; } - // Scope guard: remove the temp file even if parse_problem throws. - // std::error_code is passed so the destructor does not throw during stack - // unwinding when a parse exception is propagating. - struct cleanup_t { - const std::filesystem::path& path; - ~cleanup_t() - { - std::error_code ec; - std::filesystem::remove(path, ec); - } - } cleanup{tmp}; return parse_problem(tmp.string()); } @@ -2881,24 +2878,21 @@ 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"; + std::string input_file = cuopt::test::get_rapids_dataset_root_dir() + "/qcqp/p0033_qc1.mps"; + temp_file_t temp_file(".mps"); + temp_file_t temp_file_2(".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); + writer.write(temp_file.string()); - auto reloaded = parse_mps(temp_file, false); + auto reloaded = parse_mps(temp_file.string(), false); mps_writer_t writer_r2(reloaded); - writer_r2.write(temp_file_2); - auto reloaded_2 = parse_mps(temp_file_2, false); + writer_r2.write(temp_file_2.string()); + auto reloaded_2 = parse_mps(temp_file_2.string(), false); compare_data_models(reloaded, reloaded_2); - - std::filesystem::remove(temp_file); - std::filesystem::remove(temp_file_2); } } // namespace cuopt::linear_programming::io From 7047c39e5e54893dcaee36f809ccef2a2ddf85a0 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Mon, 18 May 2026 00:17:18 +0000 Subject: [PATCH 17/18] docs: fix mps_datamodel_example docstring to name ParseMps The "This example demonstrates how to" bullet pointed at the package path cuopt.linear_programming.io after the recent rename, but the example code calls ParseMps directly (imported from the public top-level). Update the bullet to name the actual function so the prose matches the sample. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cuopt-server/examples/lp/examples/mps_datamodel_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py b/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py index 04be2e6987..be372f4bd5 100644 --- a/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py +++ b/docs/cuopt/source/cuopt-server/examples/lp/examples/mps_datamodel_example.py @@ -4,7 +4,7 @@ LP DataModel from MPS Parser Example This example demonstrates how to: -- Parse an MPS file using cuopt.linear_programming.io +- Parse an MPS file using cuopt.linear_programming.ParseMps - Create a DataModel from the parsed MPS - Solve using the DataModel via the server - Extract detailed solution information From 9567265d738fb44fc89573befb49b5dc7fdeae37 Mon Sep 17 00:00:00 2001 From: Miles Lubin Date: Mon, 18 May 2026 00:32:25 +0000 Subject: [PATCH 18/18] cuOptReadProblem: validate inputs and return CUOPT_INVALID_ARGUMENT The C API entry point previously dereferenced filename via std::string(filename) without a null check and stored into *problem_ptr without a null check on the out-pointer. A null filename would segfault inside libstdc++'s string constructor, and a null problem_ptr would segfault on assignment; an empty filename would advance into the generic file-not-found / parse-error paths. Validate filename != nullptr && filename[0] != '\0' && problem_ptr != nullptr up front, return CUOPT_INVALID_ARGUMENT immediately on failure, and (importantly) skip the `new problem_and_stream_view_t` allocation that would otherwise leak when the validation fails. The existing parser exception path already handles cleanup for non-null inputs. Document the new return code on the public header docstring and add a test (c_api.read_problem_null_or_empty_inputs_rejected) covering all three invalid combinations. Signed-off-by: Miles Lubin Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/include/cuopt/linear_programming/cuopt_c.h | 12 ++++++++---- cpp/src/pdlp/cuopt_c.cpp | 7 +++++++ .../linear_programming/c_api_tests/c_api_tests.cpp | 13 +++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cpp/include/cuopt/linear_programming/cuopt_c.h b/cpp/include/cuopt/linear_programming/cuopt_c.h index 6665ba0fac..74071a5416 100644 --- a/cpp/include/cuopt/linear_programming/cuopt_c.h +++ b/cpp/include/cuopt/linear_programming/cuopt_c.h @@ -108,12 +108,16 @@ cuopt_int_t cuOptGetVersion(cuopt_int_t* version_major, * - ".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[in] filename - The path to the MPS, QPS, or LP file. Must be a + * non-null, non-empty C string. * - * @param[out] problem_ptr - A pointer to a cuOptOptimizationProblem. On output - * the problem will be created and initialized with the data from the input file. + * @param[out] problem_ptr - A non-null pointer to a cuOptOptimizationProblem. + * On output the problem will be created and initialized with the data from + * the input file. * - * @return A status code indicating success or failure. + * @return A status code indicating success or failure. Returns + * CUOPT_INVALID_ARGUMENT if filename is null or empty, or if problem_ptr is + * null. */ cuopt_int_t cuOptReadProblem(const char* filename, cuOptOptimizationProblem* problem_ptr); diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index 42c083fdbf..c1142afeef 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -101,6 +101,13 @@ cuopt_int_t cuOptGetVersion(cuopt_int_t* version_major, cuopt_int_t cuOptReadProblem(const char* filename, cuOptOptimizationProblem* problem_ptr) { + // Validate C-API inputs before any allocation. A null/empty filename or a + // null out-pointer cannot succeed and must not leave the user with a + // partially-constructed problem_and_stream_view_t. + if (filename == nullptr || filename[0] == '\0' || problem_ptr == nullptr) { + return CUOPT_INVALID_ARGUMENT; + } + problem_and_stream_view_t* problem_and_stream = new problem_and_stream_view_t(get_memory_backend_type()); std::string filename_str(filename); 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 35d6c2dd1d..6fb4d83c42 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 @@ -115,6 +115,19 @@ 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); } +TEST(c_api, read_problem_null_or_empty_inputs_rejected) +{ + cuOptOptimizationProblem handle = nullptr; + // Null filename pointer. + EXPECT_EQ(cuOptReadProblem(nullptr, &handle), CUOPT_INVALID_ARGUMENT); + EXPECT_EQ(handle, nullptr); + // Empty filename string. + EXPECT_EQ(cuOptReadProblem("", &handle), CUOPT_INVALID_ARGUMENT); + EXPECT_EQ(handle, nullptr); + // Null out-pointer. + EXPECT_EQ(cuOptReadProblem("any.lp", nullptr), CUOPT_INVALID_ARGUMENT); +} + // 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.