diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 04ac3355ab..a4e9e30a4b 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -272,4 +272,24 @@ #define CUOPT_STRING_ARRAY_VARIABLE_NAMES 200 #define CUOPT_STRING_ARRAY_ROW_NAMES 201 +/* @brief Scalar solution attribute selectors + * (see cuOptGetSolution{Int,Float}Attribute). Passed as cuopt_int_t; numbered in a separate + * range from the problem selectors. + * + * Which of these a solution carries depends on the class of problem that produced it; the + * accessors return CUOPT_INVALID_ARGUMENT for one that does not apply. Use CUOPT_ATTR_IS_MIP on + * the originating problem to determine the class. + */ +#define CUOPT_SOLUTION_ATTR_LP_PRIMAL_RESIDUAL 300 +#define CUOPT_SOLUTION_ATTR_LP_DUAL_RESIDUAL 301 +#define CUOPT_SOLUTION_ATTR_LP_GAP 302 +#define CUOPT_SOLUTION_ATTR_LP_NUM_ITERATIONS 303 +#define CUOPT_SOLUTION_ATTR_LP_SOLVED_BY 304 +#define CUOPT_SOLUTION_ATTR_MIP_PRESOLVE_TIME 305 +#define CUOPT_SOLUTION_ATTR_MIP_NUM_NODES 306 +#define CUOPT_SOLUTION_ATTR_MIP_NUM_SIMPLEX_ITERATIONS 307 +#define CUOPT_SOLUTION_ATTR_MIP_MAX_CONSTRAINT_VIOLATION 308 +#define CUOPT_SOLUTION_ATTR_MIP_MAX_INT_VIOLATION 309 +#define CUOPT_SOLUTION_ATTR_MIP_MAX_VARIABLE_BOUND_VIOLATION 310 + #endif // CUOPT_CONSTANTS_H diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index 82f0fd281f..c133ab8f8e 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -1110,6 +1110,60 @@ cuopt_int_t cuOptGetDualObjectiveValue(cuOptSolution solution, */ cuopt_int_t cuOptGetReducedCosts(cuOptSolution solution, cuopt_float_t* reduced_cost_ptr); +/* -------------------------------------------------------------------------- */ +/* Solution attributes */ +/* -------------------------------------------------------------------------- */ + +/* + * A solution attribute is a read-only value describing a completed solve, selected by one of the + * CUOPT_SOLUTION_ATTR_* integer constants in constants.h and passed as cuopt_int_t. The + * attributes available here are solver statistics: residuals, gap, iteration and node counts, + * presolve time, and violation magnitudes. + * + * Attributes are distinct from parameters. A parameter is an input, set on a cuOptSolverSettings + * before solving with cuOptSetParameter and read back with cuOptGetParameter. An attribute is an + * output, read from a solved cuOptSolution, or from a cuOptOptimizationProblem in the case of the + * problem attributes further below. + * + * Not every attribute applies to every solution: which statistics a solve produces depends on the + * class of problem it was given. An attribute that does not apply returns CUOPT_INVALID_ARGUMENT. + * Use CUOPT_ATTR_IS_MIP on the originating problem to determine the class. + */ + +/** @brief Get a scalar integer solution attribute (a CUOPT_SOLUTION_ATTR_* with an integer + * value: iteration counts, node counts, or the method that solved the problem). + * + * @param[in] solution - The solution object. + * + * @param[in] attribute - The attribute selector. + * + * @param[out] value_out - A pointer to a cuopt_int_t that on output will contain the value. + * + * @return A status code indicating success or failure. Returns CUOPT_INVALID_ARGUMENT if the + * selector is unknown, does not have an integer value, or does not apply to this solution's + * problem class. + */ +cuopt_int_t cuOptGetSolutionIntAttribute(cuOptSolution solution, + cuopt_int_t attribute, + cuopt_int_t* value_out); + +/** @brief Get a scalar floating-point solution attribute (a CUOPT_SOLUTION_ATTR_* with a + * floating-point value: residuals, gap, presolve time, or violation magnitudes). + * + * @param[in] solution - The solution object. + * + * @param[in] attribute - The attribute selector. + * + * @param[out] value_out - A pointer to a cuopt_float_t that on output will contain the value. + * + * @return A status code indicating success or failure. Returns CUOPT_INVALID_ARGUMENT if the + * selector is unknown, does not have a floating-point value, or does not apply to this + * solution's problem class. + */ +cuopt_int_t cuOptGetSolutionFloatAttribute(cuOptSolution solution, + cuopt_int_t attribute, + cuopt_float_t* value_out); + /* -------------------------------------------------------------------------- */ /* Generic problem attributes */ /* -------------------------------------------------------------------------- */ diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index a813abf71f..95399b673b 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -34,6 +35,8 @@ using cuopt::mathematical_optimization::csc_matrix_t; using cuopt::mathematical_optimization::csr_matrix_t; using cuopt::mathematical_optimization::get_memory_backend_type; using cuopt::mathematical_optimization::is_valid_public_var_type_code; +using cuopt::mathematical_optimization::lp_solution_interface_t; +using cuopt::mathematical_optimization::mip_solution_interface_t; using cuopt::mathematical_optimization::optimization_problem_interface_t; using cuopt::mathematical_optimization::problem_and_stream_view_t; using cuopt::mathematical_optimization::problem_category_t; @@ -1362,6 +1365,99 @@ cuopt_int_t cuOptGetReducedCosts(cuOptSolution solution, cuopt_float_t* reduced_ } } +namespace { + +// Solution attribute plumbing. Each selector names one scalar on the LP or MIP solution +// interface; adding a statistic later means adding a constant and one line, not a new symbol. + +lp_solution_interface_t* as_lp_solution(cuOptSolution solution) +{ + auto* view = static_cast(solution); + return view->is_mip ? nullptr : view->lp_solution_interface_ptr; +} + +mip_solution_interface_t* as_mip_solution(cuOptSolution solution) +{ + auto* view = static_cast(solution); + return view->is_mip ? view->mip_solution_interface_ptr : nullptr; +} + +} // namespace + +// Each case states which kind of solution it reads, so a selector's numeric value carries no +// meaning beyond identity and new selectors can be appended anywhere. +#define CUOPT_READ_LP_ATTRIBUTE(selector, getter, cast_to) \ + case selector: { \ + auto* lp = as_lp_solution(solution); \ + if (lp == nullptr) { return CUOPT_INVALID_ARGUMENT; } \ + *value_out = static_cast(lp->getter()); \ + return CUOPT_SUCCESS; \ + } + +#define CUOPT_READ_MIP_ATTRIBUTE(selector, getter, cast_to) \ + case selector: { \ + auto* mip = as_mip_solution(solution); \ + if (mip == nullptr) { return CUOPT_INVALID_ARGUMENT; } \ + *value_out = static_cast(mip->getter()); \ + return CUOPT_SUCCESS; \ + } + +cuopt_int_t cuOptGetSolutionIntAttribute(cuOptSolution solution, + cuopt_int_t attribute, + cuopt_int_t* value_out) +{ + if (solution == nullptr) { return CUOPT_INVALID_ARGUMENT; } + if (value_out == nullptr) { return CUOPT_INVALID_ARGUMENT; } + + try { + switch (attribute) { + CUOPT_READ_LP_ATTRIBUTE( + CUOPT_SOLUTION_ATTR_LP_NUM_ITERATIONS, get_num_iterations, cuopt_int_t) + CUOPT_READ_LP_ATTRIBUTE(CUOPT_SOLUTION_ATTR_LP_SOLVED_BY, solved_by, cuopt_int_t) + CUOPT_READ_MIP_ATTRIBUTE(CUOPT_SOLUTION_ATTR_MIP_NUM_NODES, get_num_nodes, cuopt_int_t) + CUOPT_READ_MIP_ATTRIBUTE( + CUOPT_SOLUTION_ATTR_MIP_NUM_SIMPLEX_ITERATIONS, get_num_simplex_iterations, cuopt_int_t) + default: return CUOPT_INVALID_ARGUMENT; + } + } catch (const std::exception& e) { + return CUOPT_RUNTIME_ERROR; + } +} + +cuopt_int_t cuOptGetSolutionFloatAttribute(cuOptSolution solution, + cuopt_int_t attribute, + cuopt_float_t* value_out) +{ + if (solution == nullptr) { return CUOPT_INVALID_ARGUMENT; } + if (value_out == nullptr) { return CUOPT_INVALID_ARGUMENT; } + + try { + switch (attribute) { + CUOPT_READ_LP_ATTRIBUTE( + CUOPT_SOLUTION_ATTR_LP_PRIMAL_RESIDUAL, get_l2_primal_residual, cuopt_float_t) + CUOPT_READ_LP_ATTRIBUTE( + CUOPT_SOLUTION_ATTR_LP_DUAL_RESIDUAL, get_l2_dual_residual, cuopt_float_t) + CUOPT_READ_LP_ATTRIBUTE(CUOPT_SOLUTION_ATTR_LP_GAP, get_gap, cuopt_float_t) + CUOPT_READ_MIP_ATTRIBUTE( + CUOPT_SOLUTION_ATTR_MIP_PRESOLVE_TIME, get_presolve_time, cuopt_float_t) + CUOPT_READ_MIP_ATTRIBUTE(CUOPT_SOLUTION_ATTR_MIP_MAX_CONSTRAINT_VIOLATION, + get_max_constraint_violation, + cuopt_float_t) + CUOPT_READ_MIP_ATTRIBUTE( + CUOPT_SOLUTION_ATTR_MIP_MAX_INT_VIOLATION, get_max_int_violation, cuopt_float_t) + CUOPT_READ_MIP_ATTRIBUTE(CUOPT_SOLUTION_ATTR_MIP_MAX_VARIABLE_BOUND_VIOLATION, + get_max_variable_bound_violation, + cuopt_float_t) + default: return CUOPT_INVALID_ARGUMENT; + } + } catch (const std::exception& e) { + return CUOPT_RUNTIME_ERROR; + } +} + +#undef CUOPT_READ_LP_ATTRIBUTE +#undef CUOPT_READ_MIP_ATTRIBUTE + /* -------------------------------------------------------------------------- */ /* Generic problem attribute getters */ /* -------------------------------------------------------------------------- */ 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 ed0e017cae..e1a11057ef 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 @@ -7,7 +7,9 @@ #include "c_api_tests.h" +#include #include +#include #include #include #include @@ -1077,3 +1079,158 @@ TEST(c_api, problem_attributes_names) // Note: cuopt_cli subprocess tests are in Python (test_cpu_only_execution.py) // which provides better cross-platform subprocess handling + +// ============================================================================= +// Solution attributes +// +// Solver statistics are read through the scalar solution attribute accessors rather than +// dedicated getters, so a new statistic is a new constant instead of a new exported symbol. +// ============================================================================= + +namespace { + +// Destroys the solution however the test leaves scope. The checks below use ASSERT, which +// returns early on failure, so an explicit destroy at the end of the test would be skipped +// exactly when a test fails and leak the solution into the rest of the binary. +class scoped_solution_t { + public: + explicit scoped_solution_t(cuOptSolution solution) : solution_(solution) {} + ~scoped_solution_t() + { + if (solution_ != nullptr) { cuOptDestroySolution(&solution_); } + } + scoped_solution_t(const scoped_solution_t&) = delete; + scoped_solution_t& operator=(const scoped_solution_t&) = delete; + + cuOptSolution get() const { return solution_; } + + private: + cuOptSolution solution_; +}; + +// Builds and solves a two-variable problem, integral when `mip` is set. +cuOptSolution solve_tiny_problem(bool mip) +{ + cuopt_int_t row_offsets[] = {0, 2}; + cuopt_int_t column_indices[] = {0, 1}; + cuopt_float_t matrix_values[] = {1.0, 1.0}; + cuopt_float_t objective[] = {-1.0, -1.0}; + cuopt_float_t rhs[] = {3.5}; + char constraint_sense[] = {CUOPT_LESS_THAN}; + cuopt_float_t lower_bounds[] = {0.0, 0.0}; + cuopt_float_t upper_bounds[] = {10.0, 10.0}; + char variable_types[] = {mip ? CUOPT_INTEGER : CUOPT_CONTINUOUS, + mip ? CUOPT_INTEGER : CUOPT_CONTINUOUS}; + + cuOptOptimizationProblem problem = nullptr; + cuOptSolverSettings settings = nullptr; + cuOptSolution solution = nullptr; + EXPECT_EQ(cuOptCreateProblem(1, + 2, + CUOPT_MINIMIZE, + 0, + objective, + row_offsets, + column_indices, + matrix_values, + constraint_sense, + rhs, + lower_bounds, + upper_bounds, + variable_types, + &problem), + CUOPT_SUCCESS); + EXPECT_EQ(cuOptCreateSolverSettings(&settings), CUOPT_SUCCESS); + EXPECT_EQ(cuOptSolve(problem, settings, &solution), CUOPT_SUCCESS); + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + return solution; +} + +} // namespace + +TEST(c_api, lp_solution_attributes) +{ + cuOptSolution raw_solution = solve_tiny_problem(false); + ASSERT_NE(raw_solution, nullptr); + scoped_solution_t scoped(raw_solution); + cuOptSolution solution = scoped.get(); + + // Seed with NaN rather than a numeric sentinel: the solver cannot legitimately report NaN, + // so "still NaN" means the accessor never wrote the value. A numeric sentinel would be + // indistinguishable from a real result. + for (cuopt_int_t attribute : {CUOPT_SOLUTION_ATTR_LP_PRIMAL_RESIDUAL, + CUOPT_SOLUTION_ATTR_LP_DUAL_RESIDUAL, + CUOPT_SOLUTION_ATTR_LP_GAP}) { + cuopt_float_t value = std::nan(""); + ASSERT_EQ(cuOptGetSolutionFloatAttribute(solution, attribute, &value), CUOPT_SUCCESS) + << "attribute " << attribute; + EXPECT_FALSE(std::isnan(value)) << "attribute " << attribute; + } + cuopt_float_t primal_residual = std::nan(""); + ASSERT_EQ(cuOptGetSolutionFloatAttribute( + solution, CUOPT_SOLUTION_ATTR_LP_PRIMAL_RESIDUAL, &primal_residual), + CUOPT_SUCCESS); + EXPECT_GE(primal_residual, 0.0); + + for (cuopt_int_t attribute : + {CUOPT_SOLUTION_ATTR_LP_NUM_ITERATIONS, CUOPT_SOLUTION_ATTR_LP_SOLVED_BY}) { + cuopt_int_t value = -1; + ASSERT_EQ(cuOptGetSolutionIntAttribute(solution, attribute, &value), CUOPT_SUCCESS) + << "attribute " << attribute; + EXPECT_GE(value, 0) << "attribute " << attribute; + } + + // Asking for a float attribute through the int accessor, and the reverse, is rejected. + cuopt_int_t as_int = 0; + cuopt_float_t as_float = 0; + EXPECT_EQ(cuOptGetSolutionIntAttribute(solution, CUOPT_SOLUTION_ATTR_LP_GAP, &as_int), + CUOPT_INVALID_ARGUMENT); + EXPECT_EQ( + cuOptGetSolutionFloatAttribute(solution, CUOPT_SOLUTION_ATTR_LP_NUM_ITERATIONS, &as_float), + CUOPT_INVALID_ARGUMENT); + + // MIP selectors do not apply to an LP solution. + EXPECT_EQ(cuOptGetSolutionIntAttribute(solution, CUOPT_SOLUTION_ATTR_MIP_NUM_NODES, &as_int), + CUOPT_INVALID_ARGUMENT); + + // Unknown selectors and null arguments are rejected. + EXPECT_EQ(cuOptGetSolutionIntAttribute(solution, 99999, &as_int), CUOPT_INVALID_ARGUMENT); + EXPECT_EQ(cuOptGetSolutionFloatAttribute(solution, CUOPT_SOLUTION_ATTR_LP_GAP, nullptr), + CUOPT_INVALID_ARGUMENT); + EXPECT_EQ(cuOptGetSolutionFloatAttribute(nullptr, CUOPT_SOLUTION_ATTR_LP_GAP, &as_float), + CUOPT_INVALID_ARGUMENT); +} + +TEST(c_api, mip_solution_attributes) +{ + cuOptSolution raw_solution = solve_tiny_problem(true); + ASSERT_NE(raw_solution, nullptr); + scoped_solution_t scoped(raw_solution); + cuOptSolution solution = scoped.get(); + + // Violations are magnitudes, so they cannot be negative. + for (cuopt_int_t attribute : {CUOPT_SOLUTION_ATTR_MIP_PRESOLVE_TIME, + CUOPT_SOLUTION_ATTR_MIP_MAX_CONSTRAINT_VIOLATION, + CUOPT_SOLUTION_ATTR_MIP_MAX_INT_VIOLATION, + CUOPT_SOLUTION_ATTR_MIP_MAX_VARIABLE_BOUND_VIOLATION}) { + cuopt_float_t value = std::nan(""); + ASSERT_EQ(cuOptGetSolutionFloatAttribute(solution, attribute, &value), CUOPT_SUCCESS) + << "attribute " << attribute; + EXPECT_FALSE(std::isnan(value)) << "attribute " << attribute; + EXPECT_GE(value, 0.0) << "attribute " << attribute; + } + + for (cuopt_int_t attribute : + {CUOPT_SOLUTION_ATTR_MIP_NUM_NODES, CUOPT_SOLUTION_ATTR_MIP_NUM_SIMPLEX_ITERATIONS}) { + cuopt_int_t value = -1; + ASSERT_EQ(cuOptGetSolutionIntAttribute(solution, attribute, &value), CUOPT_SUCCESS) + << "attribute " << attribute; + EXPECT_GE(value, 0) << "attribute " << attribute; + } + + // LP selectors do not apply to a MIP solution. + cuopt_float_t as_float = 0; + EXPECT_EQ(cuOptGetSolutionFloatAttribute(solution, CUOPT_SOLUTION_ATTR_LP_GAP, &as_float), + CUOPT_INVALID_ARGUMENT); +}