diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3f82880437..f11337758a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -64,6 +64,27 @@ jobs: date: ${{ inputs.date }} sha: ${{ inputs.sha }} script: ci/build_cpp.sh + java-build: + needs: cpp-build + permissions: + actions: read + contents: read + id-token: write + packages: read + pull-requests: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + with: + build_type: ${{ inputs.build_type || 'branch' }} + branch: ${{ inputs.branch }} + date: ${{ inputs.date }} + sha: ${{ inputs.sha }} + node_type: "cpu4" + arch: "amd64" + container_image: "rapidsai/ci-conda:26.10-latest" + script: "ci/build_java.sh" + artifact-name: "cuopt-java" + file_to_upload: "java/cuopt/target/" python-build: needs: [build-details, cpp-build] permissions: @@ -288,6 +309,7 @@ jobs: # so 'test.yaml' can be triggered without waiting for those. needs: - upload-conda + - java-build - wheel-publish-cuopt - wheel-publish-cuopt-server - wheel-publish-cuopt-sh-client diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index d54afeebd4..da3ca0c8e7 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -23,6 +23,7 @@ jobs: - checks - conda-cpp-build - conda-cpp-tests + - java-build - conda-python-build - conda-python-tests - docs-build @@ -108,12 +109,14 @@ jobs: - '!README.md' - '!SECURITY.md' - '!ci/build_wheel*.sh' + - '!ci/build_java.sh' - '!ci/check_style.sh' - '!ci/docker/**' - '!ci/release/**' - '!ci/run_*.pytests.sh' - '!ci/run_ctests.sh' - '!ci/test_cpp*.sh' + - '!ci/test_java.sh' - '!ci/test_notebooks.sh' - '!ci/test_python.sh' - '!ci/test_self_hosted_service.sh' @@ -121,6 +124,7 @@ jobs: - '!ci/thirdparty-testing/**' - '!container-builder/**' - '!helmchart/**' + - '!java/**' - '!ngc/**' - '!sonar-project.properties' - '!sonarqube/**' @@ -168,12 +172,14 @@ jobs: - '!SECURITY.md' - '!agents/**' - '!ci/build_docs.sh' + - '!ci/build_java.sh' - '!ci/build_python.sh' - '!ci/build_wheel*.sh' - '!ci/check_style.sh' - '!ci/docker/**' - '!ci/release/**' - '!ci/test_python.sh' + - '!ci/test_java.sh' - '!ci/test_self_hosted_service.sh' - '!ci/test_wheel*.sh' - '!ci/thirdparty-testing/**' @@ -184,6 +190,7 @@ jobs: - '!gemini-extension.json' - '!helmchart/**' - '!img/**' + - '!java/**' - '!ngc/**' - '!notebooks/**' - '!python/**' @@ -197,6 +204,14 @@ jobs: - '!sonarqube/**' - '!ucf/**' - '!utilities/**' + test_java: + - 'java/**' + - 'ci/build_java.sh' + - 'ci/test_java.sh' + - 'dependencies.yaml' + - '.github/workflows/pr.yaml' + - '.github/workflows/build.yaml' + - '.github/workflows/test.yaml' test_python_conda: - '**' - '!**/*.md' @@ -239,11 +254,13 @@ jobs: - '!SECURITY.md' - '!agents/**' - '!ci/build_docs.sh' + - '!ci/build_java.sh' - '!ci/build_wheel*.sh' - '!ci/check_style.sh' - '!ci/docker/**' - '!ci/release/**' - '!ci/test_self_hosted_service.sh' + - '!ci/test_java.sh' - '!ci/test_wheel*.sh' - '!ci/thirdparty-testing/**' - '!ci/utils/sync_skills_version.sh' @@ -253,6 +270,7 @@ jobs: - '!gemini-extension.json' - '!helmchart/**' - '!img/**' + - '!java/**' - '!ngc/**' - '!notebooks/**' - '!skills/**/SKILL.md' @@ -309,11 +327,13 @@ jobs: - '!ci/build_cpp.sh' - '!ci/build_docs.sh' - '!ci/build_python.sh' + - '!ci/build_java.sh' - '!ci/check_style.sh' - '!ci/docker/**' - '!ci/release/**' - '!ci/run_ctests.sh' - '!ci/test_python.sh' + - '!ci/test_java.sh' - '!ci/thirdparty-testing/**' - '!ci/utils/sync_skills_version.sh' - '!ci/utils/validate_skills.sh' @@ -322,6 +342,7 @@ jobs: - '!gemini-extension.json' - '!helmchart/**' - '!img/**' + - '!java/**' - '!ngc/**' - '!notebooks/**' - '!skills/**/SKILL.md' @@ -343,9 +364,10 @@ jobs: ignored_pr_jobs: "pr-test-summary" conda-cpp-build: needs: [build-details, checks, compute-matrix-filters, changed-files] - # Consumed by conda-cpp-tests, conda-python-build, and (transitively) docs-build. + # Consumed by C++, Java, Python, and docs jobs. if: >- fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_conda || fromJSON(needs.changed-files.outputs.changed_file_groups).build_docs permissions: @@ -443,6 +465,27 @@ jobs: artifact-name: "cuopt_docs" container_image: "rapidsai/ci-conda:26.10-latest" script: "ci/build_docs.sh" + java-build: + needs: [conda-cpp-build, changed-files] + permissions: + actions: read + contents: read + id-token: write + packages: read + pull-requests: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + if: >- + fromJSON(needs.changed-files.outputs.changed_file_groups).test_java || + fromJSON(needs.changed-files.outputs.changed_file_groups).test_cpp + with: + build_type: pull-request + node_type: "gpu-l4-latest-1" + arch: "amd64" + container_image: "rapidsai/ci-conda:26.10-latest" + script: "ci/test_java.sh" + artifact-name: "cuopt-java" + file_to_upload: "java/cuopt/target/" wheel-build-libcuopt: needs: [build-details, compute-matrix-filters, changed-files] if: fromJSON(needs.changed-files.outputs.changed_file_groups).test_python_wheels diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a5f1e17252..b287bcb990 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -73,6 +73,25 @@ jobs: script-env-secret-3-key: CUOPT_AWS_SECRET_ACCESS_KEY script-env-secret-3-value: ${{ secrets.CUOPT_AWS_SECRET_ACCESS_KEY }} + conda-java-tests: + permissions: + actions: read + contents: read + id-token: write + packages: read + pull-requests: read + secrets: inherit # zizmor: ignore[secrets-inherit] + uses: rapidsai/shared-workflows/.github/workflows/custom-job.yaml@main + with: + build_type: ${{ inputs.build_type }} + branch: ${{ inputs.branch }} + date: ${{ inputs.date }} + sha: ${{ inputs.sha }} + node_type: "gpu-l4-latest-1" + arch: "amd64" + container_image: "rapidsai/ci-conda:26.10-latest" + script: "ci/test_java.sh" + wheel-tests-cuopt: permissions: actions: read @@ -157,6 +176,7 @@ jobs: needs: - conda-cpp-tests - conda-python-tests + - conda-java-tests - wheel-tests-cuopt - wheel-tests-cuopt-server - conda-notebook-tests diff --git a/build.sh b/build.sh index e6ed479192..4cbef2211c 100755 --- a/build.sh +++ b/build.sh @@ -14,7 +14,7 @@ ARGS=$* REPODIR=$(cd "$(dirname "$0")"; pwd) LIBCUOPT_BUILD_DIR=${LIBCUOPT_BUILD_DIR:=${REPODIR}/cpp/build} -VALIDARGS="clean codegen libcuopt cuopt_grpc_server cuopt cuopt_server cuopt_sh_client docs deb -a -b -g -fsanitize -tsan -msan -v -l= --verbose-pdlp --build-lp-only --no-fetch-rapids --skip-c-python-adapters --skip-tests-build --skip-routing-build --skip-grpc-build --skip-fatbin-write --host-lineinfo [--cmake-args=\\\"\\\"] [--cache-tool=] --install --allgpuarch --ci-only-arch --show_depr_warn -h --help" +VALIDARGS="clean codegen libcuopt cuopt_grpc_server cuopt cuopt_server cuopt_sh_client java docs deb --run-java-tests -a -b -g -fsanitize -tsan -msan -v -l= --verbose-pdlp --build-lp-only --no-fetch-rapids --skip-c-python-adapters --skip-tests-build --skip-routing-build --skip-grpc-build --skip-fatbin-write --host-lineinfo [--cmake-args=\\\"\\\"] [--cache-tool=] --install --allgpuarch --ci-only-arch --show_depr_warn -h --help" HELP="$0 [ ...] [ ...] where is: clean - remove all existing build artifacts and configuration (start over) @@ -24,6 +24,7 @@ HELP="$0 [ ...] [ ...] cuopt - build the cuopt Python package cuopt_server - build the cuopt_server Python package cuopt_sh_client - build cuopt self host client + java - build the cuopt Java bindings (requires libcuopt; needs maven and a JDK) docs - build the docs deb - build deb package (requires libcuopt to be built first) and is: @@ -36,6 +37,7 @@ HELP="$0 [ ...] [ ...] -msan - Build with MemorySanitizer (cannot be used with -fsanitize or -tsan) --install - install built libraries into the active conda environment (default: build only, no install) --no-fetch-rapids - don't fetch rapids dependencies + --run-java-tests - run the Java test suite as part of the 'java' target (needs a GPU) -l= - log level. Options are: TRACE | DEBUG | INFO | WARN | ERROR | CRITICAL | OFF. Default=INFO --verbose-pdlp - verbose mode for pdlp solver --build-lp-only - build only linear programming components, excluding routing package and MIP-specific files @@ -448,6 +450,52 @@ if buildAll || hasArg cuopt_sh_client; then python "${PYTHON_ARGS_FOR_INSTALL[@]}" . fi +# Build the Java bindings (opt-in; pass 'java' explicitly to build) +if hasArg java; then + if [ ! -f "${LIBCUOPT_BUILD_DIR}/libcuopt.so" ] && [ ! -f "${INSTALL_PREFIX}/lib/libcuopt.so" ]; then + echo "libcuopt was not found. Build it first: ./build.sh libcuopt" + exit 1 + fi + + # Prefer the build tree, so 'java' works without --install. libcuopt is compiled against + # the CPM-fetched rmm and raft under _deps, and rmm encodes its version in an inline + # namespace, so the JNI layer has to see those exact headers rather than any copy in the + # conda prefix. Mixing them links cleanly and then fails at dlopen with an undefined symbol. + if [ -f "${LIBCUOPT_BUILD_DIR}/libcuopt.so" ]; then + export CUOPT_LIBRARY="${LIBCUOPT_BUILD_DIR}/libcuopt.so" + JAVA_DEPS="${LIBCUOPT_BUILD_DIR}/_deps" + export CUOPT_EXTRA_INCLUDE_DIRS="${REPODIR}/cpp/include;${LIBCUOPT_BUILD_DIR}/include" + for dep in \ + "${JAVA_DEPS}/rmm-src/cpp/include" \ + "${JAVA_DEPS}/rmm-build/include" \ + "${JAVA_DEPS}/raft-src/cpp/include" \ + "${JAVA_DEPS}/raft-build/include" \ + "${JAVA_DEPS}/rapids_logger-src/include" \ + "${JAVA_DEPS}/cccl-src/thrust" \ + "${JAVA_DEPS}/cccl-src/libcudacxx/include" \ + "${JAVA_DEPS}/cccl-src/cub"; do + if [ -d "${dep}" ]; then + CUOPT_EXTRA_INCLUDE_DIRS="${CUOPT_EXTRA_INCLUDE_DIRS};${dep}" + fi + done + # Same reasoning at run time. libcuopt's RPATH lists the conda prefix first and DT_RPATH + # is searched ahead of LD_LIBRARY_PATH, so the CPM copies also have to be preloaded. + export CUOPT_EXTRA_LIBRARY_DIRS="${JAVA_DEPS}/rmm-build:${JAVA_DEPS}/rapids_logger-build" + export CUOPT_PRELOAD_LIBS="${JAVA_DEPS}/rmm-build/librmm.so ${JAVA_DEPS}/rapids_logger-build/librapids_logger.so" + export CUOPT_RUNTIME_LIBRARY_DIR="${LIBCUOPT_BUILD_DIR}" + fi + + export CUOPT_JAVA_NATIVE_BUILD_DIR="${CUOPT_JAVA_NATIVE_BUILD_DIR:-${REPODIR}/java/cuopt/build/native}" + if hasArg --run-java-tests; then + bash "${REPODIR}"/java/cuopt/scripts/test.sh + else + bash "${REPODIR}"/java/cuopt/scripts/build_native.sh + mvn -f "${REPODIR}"/java/cuopt/pom.xml clean package \ + -DskipTests \ + -Dcuopt.native.dir="${CUOPT_JAVA_NATIVE_BUILD_DIR}" + fi +fi + # Build the docs (opt-in; pass 'docs' explicitly to build) if hasArg docs; then cd "${REPODIR}"/cpp/doxygen diff --git a/ci/build_java.sh b/ci/build_java.sh new file mode 100755 index 0000000000..50c97dd172 --- /dev/null +++ b/ci/build_java.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +RUN_TESTS=false +if [[ "${1:-}" == "--run-java-tests" ]]; then + RUN_TESTS=true +elif [[ $# -gt 0 ]]; then + echo "Usage: $0 [--run-java-tests]" >&2 + exit 2 +fi + +if [[ -e /opt/conda/etc/profile.d/conda.sh ]]; then + . /opt/conda/etc/profile.d/conda.sh +fi + +rapids-logger "Configuring conda strict channel priority" +conda config --set channel_priority strict + +rapids-logger "Downloading the C++ artifact" +CPP_CHANNEL=$(rapids-download-from-github \ + "$(rapids-artifact-name conda_cpp libcuopt cuopt --cuda "$RAPIDS_CUDA_VERSION")") + +rapids-logger "Generating Java build dependencies" +ENV_YAML_DIR=$(mktemp -d) +rapids-dependency-file-generator \ + --output conda \ + --file-key java \ + --prepend-channel "${CPP_CHANNEL}" \ + --matrix "cuda=${RAPIDS_CUDA_VERSION%.*};arch=$(arch)" | tee "${ENV_YAML_DIR}/env.yaml" + +rapids-mamba-retry env create --yes -f "${ENV_YAML_DIR}/env.yaml" -n java \ + --channel "${CPP_CHANNEL}" + +# Temporarily allow unbound variables for conda activation. +set +u +conda activate java +set -u + +rapids-print-env + +export CUOPT_PREFIX="${CONDA_PREFIX}" + +# libcuopt comes from the conda artifact here, not a local build tree, so build.sh's 'java' +# target uses the install prefix directly. +if [[ "${RUN_TESTS}" == true ]]; then + rapids-logger "Building and testing the Java bindings" + ./build.sh java --run-java-tests +else + rapids-logger "Building the Java bindings" + ./build.sh java +fi diff --git a/ci/release/update-version.sh b/ci/release/update-version.sh index 0e776c6043..9ded71035f 100755 --- a/ci/release/update-version.sh +++ b/ci/release/update-version.sh @@ -124,6 +124,14 @@ for DEP in "${DEPENDENCIES[@]}"; do done done +# Update the Java API version. Maven has no notion of the zero-padded RAPIDS patch field, and a +# padded patch would sort oddly against a later unpadded one, so the padding is stripped here. +# This matches how cuvs versions its Java artifact. +NEXT_FULL_JAVA_TAG=$(echo "$NEXT_FULL_TAG" | sed -E 's/^([0-9]+)\.([0-9]+)\.0*([0-9]+)$/\1.\2.\3/') +for FILE in java/*/pom.xml; do + sed_runner "/.*/s//${NEXT_FULL_JAVA_TAG}<\/version>/g" "${FILE}" +done + # Update README.md version badge sed_runner 's/badge\/version-[0-9]\+\.[0-9]\+\.[0-9]\+-blue/badge\/version-'${NEXT_FULL_TAG}'-blue/g' README.md diff --git a/ci/test_java.sh b/ci/test_java.sh new file mode 100755 index 0000000000..40026caa01 --- /dev/null +++ b/ci/test_java.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +rapids-logger "Checking GPU availability" +nvidia-smi + +rapids-logger "Running the Java build and tests" +ci/build_java.sh --run-java-tests diff --git a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h index c133ab8f8e..4e7052625a 100644 --- a/cpp/include/cuopt/mathematical_optimization/cuopt_c.h +++ b/cpp/include/cuopt/mathematical_optimization/cuopt_c.h @@ -1029,7 +1029,9 @@ cuopt_int_t cuOptGetErrorString(cuOptSolution solution, * @param[in, out] solution_values - A pointer to an array of type cuopt_float_t of size * num_variables that will contain the solution values. * - * @return A status code indicating success or failure. + * @return A status code indicating success or failure. Returns CUOPT_INVALID_ARGUMENT if the + * solve produced no primal solution, for instance when the problem was infeasible; the output + * buffer is not written in that case. */ cuopt_int_t cuOptGetPrimalSolution(cuOptSolution solution, cuopt_float_t* solution_values); @@ -1083,7 +1085,8 @@ cuopt_int_t cuOptGetSolutionBound(cuOptSolution solution, cuopt_float_t* solutio * @param[in, out] dual_solution_ptr - A pointer to an array of type cuopt_float_t of size * num_constraints that will contain the dual solution. * - * @return A status code indicating success or failure. + * @return A status code indicating success or failure. Returns CUOPT_INVALID_ARGUMENT if the + * solve produced no dual solution; the output buffer is not written in that case. */ cuopt_int_t cuOptGetDualSolution(cuOptSolution solution, cuopt_float_t* dual_solution_ptr); @@ -1106,7 +1109,8 @@ cuopt_int_t cuOptGetDualObjectiveValue(cuOptSolution solution, * @param[in,out] reduced_cost_ptr - A pointer to an array of type cuopt_float_t of size * num_variables that will contain the reduced cost. * - * @return A status code indicating success or failure. + * @return A status code indicating success or failure. Returns CUOPT_INVALID_ARGUMENT if the + * solve produced no reduced costs; the output buffer is not written in that case. */ cuopt_int_t cuOptGetReducedCosts(cuOptSolution solution, cuopt_float_t* reduced_cost_ptr); diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index 95399b673b..d1b6d3ac59 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -1264,10 +1264,15 @@ cuopt_int_t cuOptGetPrimalSolution(cuOptSolution solution, cuopt_float_t* soluti solution_and_stream_view_t* solution_and_stream_view = static_cast(solution); - const auto solution_host = solution_and_stream_view->get_solution()->get_solution_host(); - std::memcpy( - solution_values_ptr, solution_host.data(), solution_host.size() * sizeof(cuopt_float_t)); - return CUOPT_SUCCESS; + try { + const auto solution_host = solution_and_stream_view->get_solution()->get_solution_host(); + if (solution_host.empty()) { return CUOPT_INVALID_ARGUMENT; } + std::memcpy( + solution_values_ptr, solution_host.data(), solution_host.size() * sizeof(cuopt_float_t)); + return CUOPT_SUCCESS; + } catch (const std::logic_error&) { + return CUOPT_INVALID_ARGUMENT; + } } cuopt_int_t cuOptGetObjectiveValue(cuOptSolution solution, cuopt_float_t* objective_value_ptr) @@ -1326,6 +1331,7 @@ cuopt_int_t cuOptGetDualSolution(cuOptSolution solution, cuopt_float_t* dual_sol static_cast(solution); try { const auto dual_host = solution_and_stream_view->get_solution()->get_dual_solution(); + if (dual_host.empty()) { return CUOPT_INVALID_ARGUMENT; } std::memcpy(dual_solution_ptr, dual_host.data(), dual_host.size() * sizeof(cuopt_float_t)); return CUOPT_SUCCESS; } catch (const std::logic_error&) { @@ -1357,6 +1363,7 @@ cuopt_int_t cuOptGetReducedCosts(cuOptSolution solution, cuopt_float_t* reduced_ static_cast(solution); try { const auto reduced_cost_host = solution_and_stream_view->get_solution()->get_reduced_costs(); + if (reduced_cost_host.empty()) { return CUOPT_INVALID_ARGUMENT; } std::memcpy( reduced_cost_ptr, reduced_cost_host.data(), reduced_cost_host.size() * sizeof(cuopt_float_t)); return CUOPT_SUCCESS; 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 e1a11057ef..86e78b5dd7 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 @@ -1234,3 +1234,137 @@ TEST(c_api, mip_solution_attributes) EXPECT_EQ(cuOptGetSolutionFloatAttribute(solution, CUOPT_SOLUTION_ATTR_LP_GAP, &as_float), CUOPT_INVALID_ARGUMENT); } + +// ============================================================================= +// Solution accessors on a solve that produced no values +// ============================================================================= + +TEST(c_api, solution_accessors_report_absent_values) +{ + // x >= 2 and x <= 1 has no feasible point, so the solve produces no primal, dual, or reduced + // cost values. + cuopt_int_t row_offsets[] = {0, 1, 2}; + cuopt_int_t column_indices[] = {0, 0}; + cuopt_float_t matrix_values[] = {1.0, 1.0}; + cuopt_float_t objective[] = {1.0}; + cuopt_float_t rhs[] = {2.0, 1.0}; + char constraint_sense[] = {CUOPT_GREATER_THAN, CUOPT_LESS_THAN}; + cuopt_float_t lower_bounds[] = {-CUOPT_INFINITY}; + cuopt_float_t upper_bounds[] = {CUOPT_INFINITY}; + char variable_types[] = {CUOPT_CONTINUOUS}; + + cuOptOptimizationProblem problem = nullptr; + cuOptSolverSettings settings = nullptr; + cuOptSolution raw_solution = nullptr; + ASSERT_EQ(cuOptCreateProblem(2, + 1, + CUOPT_MINIMIZE, + 0, + objective, + row_offsets, + column_indices, + matrix_values, + constraint_sense, + rhs, + lower_bounds, + upper_bounds, + variable_types, + &problem), + CUOPT_SUCCESS); + ASSERT_EQ(cuOptCreateSolverSettings(&settings), CUOPT_SUCCESS); + ASSERT_EQ(cuOptSolve(problem, settings, &raw_solution), CUOPT_SUCCESS); + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + + ASSERT_NE(raw_solution, nullptr); + scoped_solution_t scoped(raw_solution); + cuOptSolution solution = scoped.get(); + + cuopt_int_t termination_status = -1; + ASSERT_EQ(cuOptGetTerminationStatus(solution, &termination_status), CUOPT_SUCCESS); + ASSERT_EQ(termination_status, CUOPT_TERMINATION_STATUS_INFEASIBLE); + + // The buffers carry a sentinel no solve would produce. Each accessor must report the absence + // rather than returning success having written nothing, which would leave the caller reading + // whatever the buffer already held and unable to tell that from a real result. + const cuopt_float_t sentinel = -12345.0; + cuopt_float_t primal[1] = {sentinel}; + cuopt_float_t dual[2] = {sentinel, sentinel}; + cuopt_float_t reduced[1] = {sentinel}; + + EXPECT_EQ(cuOptGetPrimalSolution(solution, primal), CUOPT_INVALID_ARGUMENT); + EXPECT_EQ(cuOptGetDualSolution(solution, dual), CUOPT_INVALID_ARGUMENT); + EXPECT_EQ(cuOptGetReducedCosts(solution, reduced), CUOPT_INVALID_ARGUMENT); + + EXPECT_EQ(primal[0], sentinel); + EXPECT_EQ(dual[0], sentinel); + EXPECT_EQ(dual[1], sentinel); + EXPECT_EQ(reduced[0], sentinel); +} + +TEST(c_api, solution_accessors_on_a_problem_with_no_constraints) +{ + // A box-constrained LP with no constraints solves to optimality. Its primal and reduced-cost + // vectors are populated; its dual vector is empty because there are no constraints to have + // duals for, and asking for it reports CUOPT_INVALID_ARGUMENT. + cuopt_int_t row_offsets[] = {0}; + cuopt_int_t column_indices[] = {0}; + cuopt_float_t matrix_values[] = {0.0}; + cuopt_float_t objective[] = {1.0}; + cuopt_float_t rhs[] = {0.0}; + char constraint_sense[] = {CUOPT_LESS_THAN}; + cuopt_float_t lower_bounds[] = {0.0}; + cuopt_float_t upper_bounds[] = {5.0}; + char variable_types[] = {CUOPT_CONTINUOUS}; + + cuOptOptimizationProblem problem = nullptr; + cuOptSolverSettings settings = nullptr; + cuOptSolution raw_solution = nullptr; + ASSERT_EQ(cuOptCreateProblem(0, + 1, + CUOPT_MINIMIZE, + 0, + objective, + row_offsets, + column_indices, + matrix_values, + constraint_sense, + rhs, + lower_bounds, + upper_bounds, + variable_types, + &problem), + CUOPT_SUCCESS); + ASSERT_EQ(cuOptCreateSolverSettings(&settings), CUOPT_SUCCESS); + ASSERT_EQ(cuOptSolve(problem, settings, &raw_solution), CUOPT_SUCCESS); + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + + ASSERT_NE(raw_solution, nullptr); + scoped_solution_t scoped(raw_solution); + cuOptSolution solution = scoped.get(); + + cuopt_int_t termination_status = -1; + ASSERT_EQ(cuOptGetTerminationStatus(solution, &termination_status), CUOPT_SUCCESS); + ASSERT_EQ(termination_status, CUOPT_TERMINATION_STATUS_OPTIMAL); + + const cuopt_float_t sentinel = -12345.0; + cuopt_float_t primal[1] = {sentinel}; + cuopt_float_t reduced[1] = {sentinel}; + cuopt_float_t dual[1] = {sentinel}; + + // minimize x over 0 <= x <= 5, so the optimum sits at the lower bound with the objective + // coefficient as its reduced cost. + EXPECT_EQ(cuOptGetPrimalSolution(solution, primal), CUOPT_SUCCESS); + EXPECT_EQ(cuOptGetReducedCosts(solution, reduced), CUOPT_SUCCESS); + EXPECT_NEAR(primal[0], 0.0, 1e-6); + EXPECT_NEAR(reduced[0], 1.0, 1e-6); + + cuopt_float_t objective_value = sentinel; + EXPECT_EQ(cuOptGetObjectiveValue(solution, &objective_value), CUOPT_SUCCESS); + EXPECT_NEAR(objective_value, 0.0, 1e-6); + + // No constraints means no dual vector to return. + EXPECT_EQ(cuOptGetDualSolution(solution, dual), CUOPT_INVALID_ARGUMENT); + EXPECT_EQ(dual[0], sentinel); +} diff --git a/dependencies.yaml b/dependencies.yaml index c9eb8d3239..d6a76f3c1a 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -45,6 +45,19 @@ files: - test_cpp - test_cpp_cuopt - depends_on_libcuopt + java: + output: none + includes: + - build_common + - cuda + - cuda_version + - depends_on_libcuopt + # The JNI layer compiles against libcuopt's public headers, which include + # raft, rmm and rapids_logger headers transitively. + - depends_on_libraft_headers + - depends_on_librmm + - depends_on_rapids_logger + - java test_python: output: none includes: @@ -277,6 +290,12 @@ dependencies: - libprotobuf - libabseil - re2 + java: + common: + - output_types: conda + packages: + - maven + - openjdk=11.* test_cpp: common: - output_types: [conda] diff --git a/docs/cuopt/source/cuopt-java/convex/convex-api.rst b/docs/cuopt/source/cuopt-java/convex/convex-api.rst new file mode 100644 index 0000000000..3ab78cfb4e --- /dev/null +++ b/docs/cuopt/source/cuopt-java/convex/convex-api.rst @@ -0,0 +1,137 @@ +=================================== +Convex Optimization API Reference +=================================== + +The Java LP/MIP/QP bindings are in the package +``com.nvidia.cuopt.mathematicalprogramming``. The public API is documented below by +role. Method names are Java names and therefore use fluent methods instead of +Python operator overloads. + +High-Level Problem +------------------ + +``Problem`` is the recommended entry point for problems built in Java. + +.. list-table:: ``Problem`` + :header-rows: 1 + :widths: 28 72 + + * - API + - Description + * - ``new Problem()`` / ``new Problem(String name)`` + - Create an empty problem, optionally with a name. + * - ``addVariable(...)`` + - Add a variable with lower/upper bounds, objective coefficient, variable type, and name. + * - ``addConstraint(Constraint, String name)`` + - Add a linear or quadratic constraint. + * - ``setObjective(LinearExpression, ObjectiveSense)`` + - Set a linear objective. + * - ``setObjective(QuadraticExpression, ObjectiveSense)`` + - Set a quadratic objective with optional linear and constant terms. + * - ``solve()`` / ``solve(SolverSettings)`` + - Solve the problem and return a ``Solution``. + * - ``getConstraintMatrix()`` / ``getQuadraticObjectiveMatrix()`` + - Inspect the linear constraint matrix, or the quadratic objective matrix Q, in CSR form. + * - ``read(String)`` / ``write(String)`` + - Load or write a problem. The format follows the file extension; a fixed-format MPS + overload of ``read`` accepts a boolean flag. + +``Problem`` also exposes ``getVariables``, ``getVariable``, ``getConstraints``, +``getConstraint``, ``getNumVariables``, ``getNumConstraints``, +``getNumNonZeros``, ``isMIP``, ``getStatus``, ``getObjective``, +``getObjectiveValue``, and ``getSolveTime``. ``getObjective`` returns the linear +part of the objective; the quadratic part, when there is one, is available as a +matrix from ``getQuadraticObjectiveMatrix``. + +``CSRMatrix`` takes ``values``, ``columnIndices``, and ``rowOffsets`` in the +same order used by cuOpt CSR arrays. The arrays are available through +``getValues``, ``getColumnIndices``, and ``getRowOffsets``. + +Variables, Expressions, and Constraints +---------------------------------------- + +``Variable`` stores the problem index, bounds, objective coefficient, type, +name, solved value, reduced cost, and optional MIP start. Its mutable methods +return the variable so calls can be chained: + +.. code-block:: java + + Variable x = problem.addVariable( + 0.0, Double.POSITIVE_INFINITY, 1.0, + VariableType.CONTINUOUS, "x"); + x.setUpperBound(100.0).setObjectiveCoefficient(2.0); + +``LinearExpression`` supports ``of``, ``ofConstant``, ``plus``, ``minus``, +``times``, ``dividedBy``, ``constant``, and the comparison methods ``le``, +``ge``, and ``eq``. Comparisons return a ``Constraint``. + +``QuadraticExpression`` supports quadratic terms through +``QuadraticExpression.of(first, second, coefficient)`` and the same fluent +arithmetic pattern. It can also contain linear and constant terms. Its +``le`` and ``ge`` methods return quadratic constraints. It does not expose an +``eq`` method because equality quadratic constraints are not supported. + +The enums used in problem construction are: + +* ``ObjectiveSense.MINIMIZE`` and ``ObjectiveSense.MAXIMIZE``; +* ``ConstraintSense.LE``, ``ConstraintSense.GE``, and ``ConstraintSense.EQ``; +* ``VariableType.CONTINUOUS``, ``VariableType.INTEGER``, and + ``VariableType.SEMI_CONTINUOUS``. + +``Constraint`` provides ``getSense``, ``getRHS``, ``getCoefficient``, +``getLinearExpression``, ``getQuadraticExpression``, ``isQuadratic``, +``getSlack``, and ``getDualValue``. + +Solver Settings +--------------- + +``SolverSettings`` owns native solver configuration and implements +``AutoCloseable``. Settings can be set with the overloaded +``setSetting`` methods for ``String``, ``int``, ``double``, and ``boolean`` +values. Use ``getSetting`` or ``getSettingAsString`` for the native string +representation. The ``getSetting(name, type)`` overload provides a typed +``Boolean``, ``Integer``, ``Double``, or ``String`` result, for example +``getSetting(CuOptConstants.CUOPT_TIME_LIMIT, Double.class)``. + +The settings API also includes: + +* the static setting accessors; +* ``setMethod`` and ``setPDLPSolverMode``; +* ``setOptimalityTolerance``; +* MIP callback registration through ``setMIPCallback``. + +``SolverMethod`` includes ``PDLP``, ``DUAL_SIMPLEX``, ``BARRIER``, +``CONCURRENT``, and ``UNSET``. ``PDLPSolverMode`` exposes the supported PDLP +solver modes. + +Solutions and Statistics +------------------------ + +``Solution`` implements ``AutoCloseable`` and exposes: + +* ``getPrimalObjective`` and ``getDualObjective``; +* ``getTerminationStatus``; +* ``getErrorStatus`` and ``getErrorMessage``; +* ``getMIPGap`` and ``getSolutionBound`` for MIP solves; and +* ``isMIP``. + +Solution values are read from the model rather than as bulk arrays: +``Variable.getValue`` and ``Variable.getReducedCost`` for variables, and +``Constraint.getDualValue`` and ``Constraint.getSlack`` for constraints. They +are populated on the ``Problem`` after each solve. + +LP solutions additionally expose ``getLPStats``. ``LPStats`` contains primal +residual, dual residual, gap, iteration count, and the ``SolverMethod`` used. +MIP-only solution fields are documented in :doc:`../mip/mip-api`. + +MPS and Errors +-------------- + +``Problem.read`` loads a problem, choosing the parser from the file extension, +and takes a boolean overload to force fixed-format MPS. ``Problem.write`` +writes a problem for round trips or use by another cuOpt interface. + +Native failures are reported as ``CuOptException`` with a cuOpt status code +available through ``getStatusCode``. Accessing an LP-only field on a MIP +solution, or a MIP-only field on an LP solution, raises +``IllegalStateException``. diff --git a/docs/cuopt/source/cuopt-java/convex/convex-examples.rst b/docs/cuopt/source/cuopt-java/convex/convex-examples.rst new file mode 100644 index 0000000000..f14abf153d --- /dev/null +++ b/docs/cuopt/source/cuopt-java/convex/convex-examples.rst @@ -0,0 +1,136 @@ +============================ +Convex Optimization Examples +============================ + +These examples show the Java modeling patterns corresponding to the Python +LP/QP examples. They assume the Java module has been compiled as described in +:doc:`../quick-start` and that the application can load ``libcuopt_jni``. + +Simple Linear Programming +-------------------------- + +The high-level API uses fluent expressions and explicit comparison methods. + +.. code-block:: java + + import com.nvidia.cuopt.mathematicalprogramming.*; + + try (Problem problem = new Problem("simple-lp")) { + Variable x = problem.addVariable( + 0.0, Double.POSITIVE_INFINITY, 1.0, + VariableType.CONTINUOUS, "x"); + Variable y = problem.addVariable( + 0.0, Double.POSITIVE_INFINITY, 1.0, + VariableType.CONTINUOUS, "y"); + + problem.addConstraint( + LinearExpression.of(x).plus(y).ge(10.0), "demand"); + problem.setObjective( + LinearExpression.of(x).plus(y), ObjectiveSense.MINIMIZE); + + try (SolverSettings settings = new SolverSettings() + .setMethod(SolverMethod.PDLP); + Solution solution = problem.solve(settings)) { + System.out.println("Status: " + solution.getTerminationStatus()); + System.out.println("x = " + x.getValue()); + System.out.println("y = " + y.getValue()); + System.out.println("Objective = " + solution.getPrimalObjective()); + } + } + +``Problem.solve`` populates the ``Variable`` and ``Constraint`` objects after +the solve. The solution object remains available for detailed native results +and statistics. + +Simple Quadratic Programming +----------------------------- + +Quadratic objectives combine quadratic, linear, and constant terms: + +.. code-block:: java + + try (Problem problem = new Problem("simple-qp")) { + Variable x = problem.addVariable(0.0, 10.0, 0.0, VariableType.CONTINUOUS, "x"); + Variable y = problem.addVariable(0.0, 10.0, 0.0, VariableType.CONTINUOUS, "y"); + + QuadraticExpression objective = QuadraticExpression + .of(x, x, 1.0) + .plus(y, y, 1.0) + .plus(LinearExpression.of(x).times(-1.0)) + .plus(LinearExpression.of(y).times(-1.0)); + + problem.addConstraint( + LinearExpression.of(x).plus(y).eq(1.0), "sum"); + problem.setObjective(objective, ObjectiveSense.MINIMIZE); + + try (Solution solution = problem.solve()) { + System.out.println("x = " + x.getValue()); + System.out.println("y = " + y.getValue()); + System.out.println("Objective = " + solution.getPrimalObjective()); + System.out.println("LP stats gap = " + solution.getLPStats().getGap()); + } + } + +For QP solutions, ``getDualObjective`` and ``getLPStats`` are available when the +solver returns the corresponding values, and variable and constraint values are +read from the model through ``Variable.getValue``, ``Variable.getReducedCost``, +and ``Constraint.getDualValue``. + +Quadratic Constraints +--------------------- + +Quadratic constraints can be added directly to a ``Problem``: + +.. code-block:: java + + try (Problem problem = new Problem("quadratic-constraint")) { + Variable x = problem.addVariable(0.0, 10.0, 1.0, VariableType.CONTINUOUS, "x"); + Variable y = problem.addVariable(0.0, 10.0, 1.0, VariableType.CONTINUOUS, "y"); + + QuadraticExpression radius = QuadraticExpression + .of(x, x, 1.0) + .plus(y, y, 1.0); + problem.addConstraint(radius.le(4.0), "radius"); + problem.setObjective( + LinearExpression.of(x).plus(y), ObjectiveSense.MAXIMIZE); + + try (Solution solution = problem.solve()) { + System.out.println(solution.getTerminationStatus()); + } + } + +Only ``LE`` and ``GE`` quadratic constraints are supported; +``QuadraticExpression`` does not expose an ``eq`` method. + +Reading and Writing MPS/QPS +--------------------------- + +``Problem`` exposes both extension-dispatch and direct MPS entry points: + +.. code-block:: java + + try (Problem problem = Problem.read("problem.mps")) { + System.out.println("Variables: " + problem.getNumVariables()); + problem.write("roundtrip.mps"); + } + + try (Problem fixed = Problem.read("fixed-format.mps", true)) { + // Use fixed-format parsing explicitly. + } + +Parsing failures are reported as ``CuOptException`` with the cuOpt status code +available from ``getStatusCode``. + +Inspecting Solutions +-------------------- + +LP solutions expose residuals and solver metadata through ``LPStats``: + +.. code-block:: java + + try (SolverSettings settings = new SolverSettings().setMethod(SolverMethod.PDLP); + Solution solution = problem.solve(settings)) { + LPStats stats = solution.getLPStats(); + System.out.println(stats.getNumIterations()); + System.out.println(stats.getPrimalResidual()); + } diff --git a/docs/cuopt/source/cuopt-java/convex/index.rst b/docs/cuopt/source/cuopt-java/convex/index.rst new file mode 100644 index 0000000000..498fafcd85 --- /dev/null +++ b/docs/cuopt/source/cuopt-java/convex/index.rst @@ -0,0 +1,18 @@ +================================================ +Convex Optimization (LP/QP/QCQP/SOCP) +================================================ + +This section documents the Java bindings for LP, QP, QCQP, and SOCP. The Java +API is based on ``Problem``, ``Variable``, expressions, and constraints. + +Quadratic constraints are supported for ``LE`` and ``GE`` constraints. Equality +quadratic constraints are rejected by the Java API. + +.. toctree:: + :maxdepth: 3 + :caption: LP/QP/QCQP/SOCP Java API + :name: LP/QP/QCQP/SOCP Java API Reference + :titlesonly: + + convex-api.rst + convex-examples.rst diff --git a/docs/cuopt/source/cuopt-java/index.rst b/docs/cuopt/source/cuopt-java/index.rst new file mode 100644 index 0000000000..d7b30c63a3 --- /dev/null +++ b/docs/cuopt/source/cuopt-java/index.rst @@ -0,0 +1,44 @@ +==================================== +Java API +==================================== + +NVIDIA cuOpt provides experimental Java bindings for linear programming (LP), +mixed-integer linear programming (MILP), quadratic programming (QP), +quadratically constrained quadratic programming (QCQP), and second-order cone +programming (SOCP) through JNI. + +The Java bindings are a separately compiled beta module for this mathematical +programming surface. Repository CI and release workflows build and test it +against the matching ``libcuopt`` artifact, but it is not part of the top-level +cuOpt build and does not provide routing or distance-engine bindings. See +:doc:`quick-start` before using the API. + +.. note:: + + Build the module locally from ``java/cuopt`` against an existing cuOpt + installation. CI artifacts are experimental; publication to a supported + Maven repository has not been defined. + +.. toctree:: + :maxdepth: 3 + :caption: Java API Overview + :name: Java API Overview + :titlesonly: + + quick-start.rst + +.. toctree:: + :maxdepth: 3 + :caption: Convex Optimization (LP/QP/QCQP/SOCP) + :name: LP/QP Java API + :titlesonly: + + Convex Optimization + +.. toctree:: + :maxdepth: 3 + :caption: Mixed Integer Programming (MIP) + :name: MIP Java API + :titlesonly: + + Mixed Integer Programming diff --git a/docs/cuopt/source/cuopt-java/mip/index.rst b/docs/cuopt/source/cuopt-java/mip/index.rst new file mode 100644 index 0000000000..afdc154b97 --- /dev/null +++ b/docs/cuopt/source/cuopt-java/mip/index.rst @@ -0,0 +1,24 @@ +===================================== +Mixed Integer Programming (MIP) +===================================== + +.. note:: + + The cuOpt MIP solver is in beta and under active development. Finding + high-quality feasible solutions and proving optimality have different + performance characteristics; inspect termination status, MIP gap, and + solution bound in production workflows. + +The Java MIP API uses the same ``Problem``, ``SolverSettings``, and +``Solution`` classes as the LP/QP API. MIP problems are +identified by one or more variables with ``INTEGER`` or ``SEMI_CONTINUOUS`` +type. + +.. toctree:: + :maxdepth: 3 + :caption: MIP Java API + :name: MIP Java API Reference + :titlesonly: + + mip-api.rst + mip-examples.rst diff --git a/docs/cuopt/source/cuopt-java/mip/mip-api.rst b/docs/cuopt/source/cuopt-java/mip/mip-api.rst new file mode 100644 index 0000000000..8c5039c290 --- /dev/null +++ b/docs/cuopt/source/cuopt-java/mip/mip-api.rst @@ -0,0 +1,116 @@ +================= +MIP API Reference +================= + +MIP uses the shared Java problem construction and solve APIs documented in +:doc:`../convex/convex-api`. The following features are particularly relevant +to mixed-integer problems. + +Variable Types +-------------- + +Use ``VariableType`` when adding a variable or updating an existing variable: + +.. code-block:: java + + Variable integer = problem.addVariable( + 0.0, 100.0, 3.0, + VariableType.INTEGER, "integer"); + + Variable semiContinuous = problem.addVariable( + 0.0, 100.0, 1.0, + VariableType.SEMI_CONTINUOUS, "semi"); + +The supported values are ``CONTINUOUS``, ``INTEGER``, and +``SEMI_CONTINUOUS``. ``Problem.isMIP()`` and ``Solution.isMIP()`` report +whether a problem or result contains a noncontinuous variable. + +MIP Starts +---------- + +MIP starts can be provided per variable through ``Variable.setMIPStart``. The +high-level ``Problem.solve`` collects defined variable starts and passes them +to the native solver. A complete start can also be supplied directly through +``SolverSettings.addMIPStart(double[])``. + +.. code-block:: java + + x.setMIPStart(3.0); + y.setMIPStart(2.0); + + try (SolverSettings settings = new SolverSettings()) { + // The array follows the problem's variable-index order. + settings.addMIPStart(new double[] {3.0, 2.0}); + try (Solution solution = problem.solve(settings)) { + System.out.println(solution.getMIPGap()); + } + } + +MIP Settings +------------ + +All solver settings are set through ``SolverSettings``. Use the overloaded +``setSetting`` methods for string, integer, floating-point, and boolean +values. MIP-relevant settings include time and node limits, MIP tolerances, +presolve, heuristics, scaling, determinism, and cut controls. The generated +``CuOptConstants`` class contains the string and integer constants from the +cuOpt public constants header, including every setting name. + +MIP Callbacks +------------- + +``SolverSettings.setMIPCallback`` accepts either callback interface: + +``MIPSolutionCallback`` receives each incumbent solution: + +.. code-block:: java + + settings.setMIPCallback( + (solution, objectiveValue, solutionBound, userData) -> { + System.out.println("incumbent objective = " + objectiveValue); + }, + "my-user-data", + problem.getNumVariables()); + +``MIPSetSolutionCallback`` returns a candidate solution and objective when the +native solver asks Java for one: + +.. code-block:: java + + settings.setMIPCallback( + (solutionBound, userData) -> + new MIPCallbackSolution(new double[] {3.0, 2.0}, 19.0), + null, + problem.getNumVariables()); + +Callbacks are native-runtime features. Keep the callback and any user data +valid for the duration of the solve, and close the ``SolverSettings`` after the +solve completes. Registered callbacks can be inspected with +``getMIPCallbacks``. + +MIP Solution Fields +------------------- + +For a MIP ``Solution``: + +* ``getPrimalObjective`` returns the incumbent objective value; +* ``getMIPGap`` returns the current relative MIP gap; +* ``getSolutionBound`` returns the best bound reported by the solver; +* ``getMIPStats`` returns ``MIPStats``; and +* ``getTerminationStatus``, ``getErrorStatus``, and ``getErrorMessage`` + describe the solve. + +Incumbent values are read from the model: ``Variable.getValue`` after the solve. + +``MIPStats`` contains presolve time, maximum constraint violation, maximum +integer violation, maximum variable-bound violation, node count, and simplex +iteration count. LP-only accessors such as ``getLPStats`` raise +``IllegalStateException`` for a MIP result. + +Inspecting a MIP +---------------- + +A problem can be inspected through ``getConstraintMatrix`` and +``getQuadraticObjectiveMatrix``. To examine an LP relaxation, build the problem +with ``VariableType.CONTINUOUS``, or set the types through +``Variable.setVariableType`` before solving. diff --git a/docs/cuopt/source/cuopt-java/mip/mip-examples.rst b/docs/cuopt/source/cuopt-java/mip/mip-examples.rst new file mode 100644 index 0000000000..015171a1b6 --- /dev/null +++ b/docs/cuopt/source/cuopt-java/mip/mip-examples.rst @@ -0,0 +1,117 @@ +============ +MIP Examples +============ + +These examples show mixed-integer modeling, MIP starts, semi-continuous +variables, and incumbent callbacks in Java. + +Simple MILP +----------- + +.. code-block:: java + + import com.nvidia.cuopt.mathematicalprogramming.*; + + try (Problem problem = new Problem("simple-milp")) { + Variable x = problem.addVariable( + 0.0, 100.0, 3.0, VariableType.INTEGER, "x"); + Variable y = problem.addVariable( + 0.0, 100.0, 5.0, VariableType.INTEGER, "y"); + + problem.addConstraint( + LinearExpression.of(x).times(2.0).plus(y).le(8.0), "capacity"); + problem.setObjective( + LinearExpression.of(x).times(3.0).plus(y, 5.0), + ObjectiveSense.MAXIMIZE); + + try (SolverSettings settings = new SolverSettings() + .setSetting(CuOptConstants.CUOPT_TIME_LIMIT, 10.0); + Solution solution = problem.solve(settings)) { + System.out.println("Status: " + solution.getTerminationStatus()); + System.out.println("x = " + x.getValue()); + System.out.println("y = " + y.getValue()); + System.out.println("Objective = " + solution.getPrimalObjective()); + System.out.println("MIP gap = " + solution.getMIPGap()); + System.out.println("Bound = " + solution.getSolutionBound()); + System.out.println("Nodes = " + solution.getMIPStats().getNumNodes()); + } + } + +The MIP solver can return a feasible solution before proving optimality. Use +the termination status, MIP gap, and solution bound together when interpreting +the result. + +Semi-Continuous Variables +------------------------- + +``SEMI_CONTINUOUS`` variables are zero or lie within their declared bounds. + +.. code-block:: java + + try (Problem problem = new Problem("semi-continuous")) { + Variable production = problem.addVariable( + 10.0, 100.0, 1.0, + VariableType.SEMI_CONTINUOUS, "production"); + problem.setObjective(production, ObjectiveSense.MINIMIZE); + + try (Solution solution = problem.solve()) { + System.out.println("production = " + production.getValue()); + } + } + +MIP Starts +---------- + +Set starts on variables when using the high-level ``Problem`` API: + +.. code-block:: java + + x.setMIPStart(3.0); + y.setMIPStart(2.0); + + try (SolverSettings settings = new SolverSettings(); + Solution solution = problem.solve(settings)) { + System.out.println(solution.getPrimalObjective()); + } + +For the deprecated lower-level representation, pass a full +variable-index-ordered array through +``SolverSettings.addMIPStart``. + +Incumbent Callback +------------------ + +Register an incumbent callback before solving: + +.. code-block:: java + + try (SolverSettings settings = new SolverSettings()) { + settings.setMIPCallback( + (incumbent, objective, bound, userData) -> { + System.out.println( + "incumbent objective=" + objective + ", bound=" + bound); + }, + null, + problem.getNumVariables()); + + try (Solution solution = problem.solve(settings)) { + System.out.println("Final status: " + solution.getTerminationStatus()); + } + } + +The callback receives a defensive Java array containing the incumbent vector, +the incumbent objective, the current solution bound, and the user data object. + +LP Relaxation +------------- + +Relax the integer variables before solving: + +.. code-block:: java + + for (Variable variable : problem.getVariables()) { + variable.setVariableType(VariableType.CONTINUOUS); + } + try (Solution solution = problem.solve()) { + System.out.println("LP relaxation objective = " + solution.getPrimalObjective()); + } diff --git a/docs/cuopt/source/cuopt-java/quick-start.rst b/docs/cuopt/source/cuopt-java/quick-start.rst new file mode 100644 index 0000000000..4adc914627 --- /dev/null +++ b/docs/cuopt/source/cuopt-java/quick-start.rst @@ -0,0 +1,159 @@ +Java Quick Start +================ + +The experimental Java bindings live in ``java/cuopt`` and are built explicitly +from source. Repository CI and release workflows also build and test the module +against the matching ``libcuopt`` artifact. It is not part of the top-level +cuOpt build, and a supported Maven distribution has not yet been defined. + +Requirements +------------ + +The Java module requires: + +* Java 11 or newer, with ``JAVA_HOME`` pointing to a JDK; +* a C++20 compiler; +* an existing cuOpt installation containing ``libcuopt.so``; and +* a CUDA-enabled runtime for solving problems. + +The module uses Maven for Java compilation and a Java-local CMake project for +the JNI library. The standalone native build links to +``$CUOPT_PREFIX/lib/libcuopt.so`` and places ``libcuopt_jni.so`` under +``java/cuopt/build/native``. + +.. code-block:: bash + + cd /path/to/cuopt/java/cuopt + export JAVA_HOME=/path/to/jdk-11 + export CUOPT_PREFIX=/path/to/cuopt/conda/environment + bash scripts/build_native.sh + +This builds ``java/cuopt/build/native/libcuopt_jni.so``. Java is intentionally +not part of the default cuOpt build. + +To build the native library in a different directory, set +``CUOPT_JAVA_NATIVE_BUILD_DIR``. If CUDA headers are installed outside the +usual locations, pass ``-DCUOPT_CUDA_INCLUDE_DIR=/path/to/cuda/include`` to +the CMake configure step. + +Native Loading +-------------- + +At runtime the bindings load ``libcuopt_jni``. For local development, point Java +at the directory containing the built native library: + +.. code-block:: bash + + cd java/cuopt + export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 + export CUOPT_PREFIX=/path/to/cuopt/conda/environment + export LD_LIBRARY_PATH=$CUOPT_PREFIX/targets/x86_64-linux/lib:$CUOPT_PREFIX/lib:build/native + mvn test -Dcuopt.native.dir=build/native + +The helper script combines the native build and Maven test steps: + +.. code-block:: bash + + cd /path/to/cuopt/java/cuopt + export JAVA_HOME=/path/to/jdk-11 + export CUOPT_PREFIX=/path/to/cuopt/conda/environment + bash scripts/test.sh + +To run one test class, pass its Maven property to the helper: + +.. code-block:: bash + + bash scripts/test.sh -Dtest=ProblemIntegrationTest + +Application code can use the same property: + +.. code-block:: bash + + java -Dcuopt.native.dir=/path/to/java/cuopt/build/native ... + +The Java classes load ``libcuopt_jni`` when the first binding object is +created. ``cuopt.native.dir`` must contain that library, and the cuOpt and +CUDA runtime libraries must be discoverable through ``LD_LIBRARY_PATH`` or the +native library's runtime path. The standalone native build embeds the CUDA +runtime path for the configured ``CUOPT_PREFIX``; the helper script also +exports it for Maven. + +LP Example +---------- + +The modeling API mirrors the Python concepts while using Java builder methods +instead of operator overloading. + +.. code-block:: java + + import com.nvidia.cuopt.mathematicalprogramming.*; + + Problem problem = new Problem("simple"); + Variable x = problem.addVariable(0, Double.POSITIVE_INFINITY, 0, + VariableType.CONTINUOUS, "x"); + Variable y = problem.addVariable(0, Double.POSITIVE_INFINITY, 0, + VariableType.CONTINUOUS, "y"); + + problem.addConstraint(LinearExpression.of(x).plus(y).ge(1.0), "c0"); + problem.setObjective(LinearExpression.of(x).plus(y), ObjectiveSense.MINIMIZE); + + try (SolverSettings settings = new SolverSettings().setMethod(SolverMethod.PDLP); + Solution solution = problem.solve(settings)) { + System.out.println(solution.getTerminationStatus()); + System.out.println(solution.getPrimalObjective()); + System.out.println(solution.getLPStats().getSolvedBy()); + } + +MILP Example +------------ + +.. code-block:: java + + Problem problem = new Problem("integer"); + Variable x = problem.addVariable(0, 10, 1.0, VariableType.INTEGER, "x"); + problem.addConstraint(LinearExpression.of(x).ge(1.0)); + + try (SolverSettings settings = new SolverSettings() + .setSetting(CuOptConstants.CUOPT_TIME_LIMIT, 10.0); + Solution solution = problem.solve(settings)) { + System.out.println(solution.getMIPGap()); + System.out.println(solution.getMIPStats().getNumNodes()); + } + +QP Example +---------- + +.. code-block:: java + + try (Problem problem = new Problem("quadratic")) { + Variable x = problem.addVariable(0.0, 10.0, 0.0, VariableType.CONTINUOUS, "x"); + Variable y = problem.addVariable(0.0, 10.0, 0.0, VariableType.CONTINUOUS, "y"); + problem.addConstraint(LinearExpression.of(x).plus(y).ge(5.0)); + problem.setObjective( + QuadraticExpression.of(x, x, 1.0).plus(y, y, 4.0), + ObjectiveSense.MINIMIZE); + try (Solution solution = problem.solve()) { + System.out.println(solution.getPrimalObjective()); + } + } + +MPS I/O +------- + +.. code-block:: java + + try (Problem problem = Problem.read("problem.mps")) { + problem.write("roundtrip.mps"); + } + +Lifecycle +--------- + +``SolverSettings`` and ``Solution`` own native handles and implement +``AutoCloseable``. Prefer try-with-resources. They also register a ``Cleaner`` +fallback, but deterministic close keeps native memory pressure predictable. + +The Java module is not a drop-in translation of Python syntax. Java uses +fluent expression methods such as ``plus``, ``minus``, ``le``, ``ge``, and +``eq`` instead of Python operator overloads. The following pages document the +implemented LP/MILP/QP/QCQP/SOCP surface and its Java names. diff --git a/docs/cuopt/source/index.rst b/docs/cuopt/source/index.rst index 266aaf7b47..05cfc68000 100644 --- a/docs/cuopt/source/index.rst +++ b/docs/cuopt/source/index.rst @@ -42,6 +42,16 @@ Python (cuopt) Python Overview +========================== +Java (cuopt) +========================== +.. toctree:: + :maxdepth: 4 + :caption: Java API + :name: Java API + + Java Overview + ==================================== gRPC Remote Execution ==================================== diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 0000000000..3d9173556a --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,3 @@ +*.iml +hs_err*.log +target/ diff --git a/java/cuopt/CMakeLists.txt b/java/cuopt/CMakeLists.txt new file mode 100644 index 0000000000..7b1a40c714 --- /dev/null +++ b/java/cuopt/CMakeLists.txt @@ -0,0 +1,78 @@ +# cmake-format: off +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# cmake-format: on + +cmake_minimum_required(VERSION 3.22) +project(cuopt_java_native LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +set(CUOPT_PREFIX "" CACHE PATH "cuOpt installation prefix") +if(NOT CUOPT_PREFIX) + set(CUOPT_PREFIX "$ENV{CONDA_PREFIX}") +endif() +if(NOT CUOPT_PREFIX) + message(FATAL_ERROR "Set CUOPT_PREFIX to the cuOpt conda environment") +endif() + +if(NOT DEFINED CUDAToolkit_ROOT AND EXISTS "${CUOPT_PREFIX}/targets") + set(CUDAToolkit_ROOT "${CUOPT_PREFIX}" CACHE PATH "CUDA toolkit root") +endif() + +find_package(JNI REQUIRED) +find_package(CUDAToolkit REQUIRED) + +set(CUOPT_INCLUDE_DIR "${CUOPT_PREFIX}/include") + +# Building against a cuOpt build tree rather than an install prefix: the top-level build.sh +# points these at cpp/build and its CPM-fetched dependencies. The rmm and raft headers must +# be the exact ones libcuopt was compiled with -- rmm puts its version in an inline namespace +# (rmm::_RMM_26_10), so mixing a different copy links cleanly and then fails at dlopen with an +# undefined symbol. +set(CUOPT_LIBRARY "" CACHE FILEPATH "Path to libcuopt.so (defaults to CUOPT_PREFIX/lib)") +set(CUOPT_EXTRA_INCLUDE_DIRS "" CACHE STRING + "Extra include directories searched before CUOPT_PREFIX/include") + +if(NOT CUOPT_LIBRARY) + set(CUOPT_LIBRARY "${CUOPT_PREFIX}/lib/libcuopt.so") +endif() +if(NOT EXISTS "${CUOPT_LIBRARY}") + message(FATAL_ERROR "cuOpt shared library was not found at ${CUOPT_LIBRARY}") +endif() + +get_target_property(CUOPT_CUDART_LIBRARY CUDA::cudart IMPORTED_LOCATION) +if(NOT CUOPT_CUDART_LIBRARY) + find_library(CUOPT_CUDART_LIBRARY NAMES cudart HINTS "${CUDAToolkit_LIBRARY_DIR}" REQUIRED) +endif() +get_filename_component(CUOPT_CUDA_RUNTIME_DIR "${CUOPT_CUDART_LIBRARY}" DIRECTORY) + +add_library(cuopt_jni SHARED + src/main/native/cuopt_jni.cpp) + +target_include_directories(cuopt_jni PRIVATE + ${JNI_INCLUDE_DIRS} + ${CUOPT_EXTRA_INCLUDE_DIRS} + ${CUOPT_INCLUDE_DIR} + ${CUOPT_PREFIX}/include/rapids + ${CUDAToolkit_INCLUDE_DIRS} + ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/src) + +target_link_libraries(cuopt_jni PRIVATE "${CUOPT_LIBRARY}" CUDA::cudart) + +# The Java module is built outside the main cuOpt build. Keep its native +# loader self-contained while allowing the script to add the cuOpt runtime +# directory supplied by the selected conda environment. +set_target_properties(cuopt_jni PROPERTIES + BUILD_RPATH "$ORIGIN" + INSTALL_RPATH "$ORIGIN") + +if(CUOPT_RUNTIME_LIBRARY_DIR) + set_property(TARGET cuopt_jni APPEND PROPERTY + BUILD_RPATH "${CUOPT_RUNTIME_LIBRARY_DIR}") +endif() + +set_property(TARGET cuopt_jni APPEND PROPERTY BUILD_RPATH "${CUOPT_CUDA_RUNTIME_DIR}") +set_property(TARGET cuopt_jni APPEND PROPERTY INSTALL_RPATH "${CUOPT_CUDA_RUNTIME_DIR}") diff --git a/java/cuopt/README.md b/java/cuopt/README.md new file mode 100644 index 0000000000..c1c4494d07 --- /dev/null +++ b/java/cuopt/README.md @@ -0,0 +1,122 @@ +# cuOpt Java bindings + +This directory contains a source module for cuOpt LP, MILP, QP, +QCQP, and SOCP Java bindings. The repository CI and release workflows build +and test the module against the matching `libcuopt` conda artifact and retain +the Maven `target/` output as a workflow artifact. Publication to a supported +Maven repository has not been defined. + +## Building + +The module is an opt-in target of the repository-level `build.sh`. It is not +part of a default build, and it is not wired into the main CMake targets. + +```bash +./build.sh libcuopt # once, to produce cpp/build/libcuopt.so +./build.sh java # build libcuopt_jni.so and package the jar +./build.sh java --run-java-tests # the same, then run the test suite +``` + +`./build.sh java` prefers the `cpp/build` tree, so it works without +`--install`. It falls back to the active conda prefix when no build tree is +present, which is what CI does with the prebuilt `libcuopt` conda artifact. + +The module can also be driven directly against an existing cuOpt installation: + +```bash +cd java/cuopt +CUOPT_PREFIX=/path/to/cuopt/conda/environment bash scripts/build_native.sh +CUOPT_PREFIX=/path/to/cuopt/conda/environment bash scripts/test.sh +``` + +`build_native.sh` builds `libcuopt_jni.so` in `build/native`. `test.sh` builds +that library and runs the Maven tests. Java 11 or newer and a C++20 compiler +are required. Native solve tests require a CUDA driver and skip automatically +when one is unavailable. + +`CUOPT_LIBRARY`, `CUOPT_EXTRA_INCLUDE_DIRS`, `CUOPT_EXTRA_LIBRARY_DIRS`, and +`CUOPT_PRELOAD_LIBS` override where the scripts look for `libcuopt` and its +dependencies; `build.sh` sets them when it targets a build tree. The rmm and +raft headers must be exactly the ones `libcuopt` was compiled against, because +rmm encodes its version in an inline namespace (`rmm::_RMM_26_10`) — mixing a +different copy links cleanly and then fails at `dlopen` with an undefined +symbol. + +The standalone native project links to `${CUOPT_PREFIX}/lib/libcuopt.so`. No +Java-specific symbol or source file is required by the main cuOpt build. + +The bindings once declared seven entry points in a Java-local shim, reaching +into the opaque solver settings handle to do it. Nothing in the settings or +solution path depends on private headers any more: + +- Solver statistics are read through `cuOptGetSolutionIntAttribute` and + `cuOptGetSolutionFloatAttribute` with the `CUOPT_SOLUTION_ATTR_*` selectors. + A new statistic is then a new constant rather than a new exported symbol, and + because `CuOptConstants.java` is generated from `constants.h`, it reaches + Java with no hand-written code. +- Whether a solution came from the MIP solver is taken from the problem's + category, which `Solution` already carries, rather than from a native call. +- The parameter-file and parameter-enumeration entry points were dropped + rather than promoted. Every solver parameter is registered from a `CUOPT_*` + macro in `constants.h`, so the generated `CuOptConstants` already carries the + complete list and runtime enumeration cannot add to it. Loading and dumping a + settings file is likewise expressible on top of `cuOptSetParameter` and + `cuOptGetParameter`. See #1705. + +The problem path is still an exception. `cuopt_jni.cpp` includes +`pdlp/cuopt_c_internal.hpp` from the checkout for the operations the C API does +not yet cover: + +- setting the problem, variable, and row names (the C API only reads them), +- reading the quadratic objective matrix and the quadratic constraint rows + (see the `TODO` in `cuopt_c.h`), +- reading variable and row names when the problem has none, which the C API + string-array getter rejects rather than reporting as empty, +- reading the problem category, +- reading the dual solution and reduced costs. These are empty when the solve + did not produce them (an infeasible LP, for instance), and the Java API + reports that as an empty array. The C API getters are copy-out into a + caller-sized buffer and report no length, so switching to them would turn + "unavailable" into a buffer of zeros. + +Closing those gaps in the C API is the remaining prerequisite for shipping this +module as a standalone binary distribution. + +## JNI symbol check + +The bindings are hand-written, so every `static native` method in +`NativeCuOpt.java` needs a matching `Java_com_nvidia_..._name` function in +`cuopt_jni.cpp`. Nothing in the compiler enforces that pairing: a missing entry +point compiles cleanly and fails at run time with `UnsatisfiedLinkError`, and a +renamed one leaves dead code behind in the library. + +`scripts/check_jni_symbols.sh` compares the prototypes `javac -h` derives from +the Java sources against the symbols the built library actually exports, and +fails on a mismatch in either direction. It reads the built library rather than +parsing the source, so the macro-generated entry points need no special casing. + +`build_native.sh` runs it after every native build, so `./build.sh java` and +both CI jobs cover it. It takes about a second. It is not a pre-commit hook, +because it needs a built `libcuopt_jni.so` and therefore a full `libcuopt` +build, which the other hooks do not require. + +To skip it while iterating — say, after adding a `native` declaration but +before writing its entry point — set `CUOPT_SKIP_JNI_SYMBOL_CHECK=1`. It can +also be run on its own once the library exists: + +```bash +cd java/cuopt +bash scripts/check_jni_symbols.sh +``` + +## Generated constants + +Maven generates `CuOptConstants.java` under +`target/generated-sources/cuopt/com/nvidia/cuopt/mathematicalprogramming/` +from `cpp/include/cuopt/mathematical_optimization/constants.h`. Do not edit the +generated file. Regenerate it after changing the C++ constants header with: + +```bash +cd java/cuopt +mvn generate-sources +``` diff --git a/java/cuopt/TESTS.md b/java/cuopt/TESTS.md new file mode 100644 index 0000000000..526b530c0c --- /dev/null +++ b/java/cuopt/TESTS.md @@ -0,0 +1,73 @@ + + +# cuOpt Java binding tests + +The Java module has three test classes under +`src/test/java/com/nvidia/cuopt/mathematicalprogramming`: + +- `ProblemModelingTest`: five pure Java modeling tests. +- `NativeIntegrationTest`: seven JNI/native cuOpt smoke tests. +- `ProblemIntegrationTest`: ten standalone Java problem and solve tests. + +The suite has no dependency on the cuOpt Python interface. + +## How to run + +Build the JNI library and run all Java tests with: + +```bash +cd /path/to/cuopt/java/cuopt +export JAVA_HOME=/path/to/jdk-11 +export CUOPT_PREFIX=/path/to/cuopt/conda/environment +bash scripts/test.sh +``` + +To run one test class: + +```bash +bash scripts/test.sh -Dtest=ProblemIntegrationTest +``` + +When invoking Maven directly, build the JNI library first and provide the +native directory: + +```bash +bash scripts/build_native.sh +mvn test -Dcuopt.native.dir=build/native +``` + +`scripts/test.sh` configures `LD_LIBRARY_PATH` for the selected cuOpt and CUDA +runtime libraries. + +## Coverage + +`ProblemModelingTest` exercises generated enum constants, legacy problem +category mapping, expression construction, CSR generation, duplicate-term +merging, problem updates, relaxation, and quadratic inspection without loading +the native library. + +`NativeIntegrationTest` covers settings, setting-file round trips, LP/MILP/QP +solves, solution statistics, error propagation, and MPS read/write paths. + +`ProblemIntegrationTest` constructs ten LP, MILP, and QP cases entirely in +Java. Each dynamic test verifies the public `Problem` model built from the +case data. The LP/MILP cases also check solve status, variable bounds, +integrality, constraint feasibility, objective values, and type-specific +solution behavior. The QP case verifies quadratic-objective and +quadratic-constraint construction; QP solve callability is covered by +`NativeIntegrationTest`. The cases cover minimization and maximization, +equality and ranged constraints, mixed bounds, mixed integer/continuous +variables, metadata, and infeasibility. + +## Prerequisite behavior + +- Pure modeling tests run without JNI or a GPU. +- Native tests skip when `cuopt.native.dir` is unset or `libcuopt_jni` is not + present. +- Solve tests skip when a CUDA driver is unavailable. + +The expected successful Maven result is `BUILD SUCCESS`. If a forked JVM +crashes, inspect `target/surefire-reports`. diff --git a/java/cuopt/pom.xml b/java/cuopt/pom.xml new file mode 100644 index 0000000000..019cc08433 --- /dev/null +++ b/java/cuopt/pom.xml @@ -0,0 +1,157 @@ + + + + 4.0.0 + + com.nvidia.cuopt + cuopt + + 26.10.0 + cuOpt Java Bindings + Java JNI bindings for cuOpt numerical optimization. + https://docs.nvidia.com/cuopt/introduction.html + + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + NVIDIA Corporation + https://github.com/NVIDIA/cuopt + + + + + scm:git:https://github.com/NVIDIA/cuopt.git + scm:git:ssh://git@github.com/NVIDIA/cuopt.git + https://github.com/NVIDIA/cuopt + HEAD + + + + 11 + UTF-8 + 5.11.4 + + + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${maven.compiler.release} + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + generate-cuopt-constants + generate-sources + + exec + + + ${project.basedir}/scripts/generate_constants.sh + + ${project.basedir}/../../cpp/include/cuopt/mathematical_optimization/constants.h + ${project.build.directory}/generated-sources/cuopt + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-generated-sources + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/cuopt + + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + + none + + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + false + + ${cuopt.native.dir} + + + + + + diff --git a/java/cuopt/scripts/build_native.sh b/java/cuopt/scripts/build_native.sh new file mode 100644 index 0000000000..89613dd45b --- /dev/null +++ b/java/cuopt/scripts/build_native.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "${MODULE_DIR}/../.." && pwd)" +CUOPT_PREFIX="${CUOPT_PREFIX:-${CONDA_PREFIX:-${REPO_ROOT}/.cuopt_env}}" +BUILD_DIR="${CUOPT_JAVA_NATIVE_BUILD_DIR:-${MODULE_DIR}/build/native}" + +source "${MODULE_DIR}/scripts/java_home.sh" +cuopt_java_setup_home javac + +CMAKE="${CMAKE:-}" +if [[ -z "${CMAKE}" && -x "${CUOPT_PREFIX}/bin/cmake" ]]; then + CMAKE="${CUOPT_PREFIX}/bin/cmake" +fi +if [[ -z "${CMAKE}" ]]; then + CMAKE="$(command -v cmake || true)" +fi +if [[ -z "${CMAKE}" ]]; then + echo "cmake was not found; use the cuOpt conda environment or set CMAKE." >&2 + exit 1 +fi + +# CUOPT_LIBRARY and CUOPT_EXTRA_INCLUDE_DIRS let the top-level build.sh point this at a +# cuOpt build tree instead of an install prefix. Left unset, the conda prefix is used. +CUOPT_LIBRARY="${CUOPT_LIBRARY:-}" +CUOPT_EXTRA_INCLUDE_DIRS="${CUOPT_EXTRA_INCLUDE_DIRS:-}" + +if [[ -z "${CUOPT_LIBRARY}" && ! -f "${CUOPT_PREFIX}/lib/libcuopt.so" ]]; then + echo "cuOpt shared library was not found at ${CUOPT_PREFIX}/lib/libcuopt.so." >&2 + echo "Build it first ('./build.sh libcuopt' from the repository root) or set CUOPT_LIBRARY." >&2 + exit 1 +fi +if [[ -n "${CUOPT_LIBRARY}" && ! -f "${CUOPT_LIBRARY}" ]]; then + echo "cuOpt shared library was not found at ${CUOPT_LIBRARY}." >&2 + exit 1 +fi + +CXX_COMPILER="${CXX:-}" +if [[ -z "${CXX_COMPILER}" && -f "${BUILD_DIR}/CMakeCache.txt" ]]; then + CACHED_CXX_COMPILER="$(sed -n 's/^CMAKE_CXX_COMPILER:.*=//p' "${BUILD_DIR}/CMakeCache.txt" | head -n 1)" + if [[ -x "${CACHED_CXX_COMPILER}" ]]; then + CXX_COMPILER="${CACHED_CXX_COMPILER}" + fi +fi +if [[ -z "${CXX_COMPILER}" && -x "${CUOPT_PREFIX}/bin/c++" ]]; then + CXX_COMPILER="${CUOPT_PREFIX}/bin/c++" +fi + +CUOPT_RUNTIME_LIBRARY_DIR="${CUOPT_RUNTIME_LIBRARY_DIR:-${CUOPT_PREFIX}/lib}" + +env -u CFLAGS -u CXXFLAGS -u CPPFLAGS -u LDFLAGS \ + "${CMAKE}" -S "${MODULE_DIR}" -B "${BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE="${CMAKE_BUILD_TYPE:-Release}" \ + -DCUOPT_PREFIX="${CUOPT_PREFIX}" \ + -DCUOPT_RUNTIME_LIBRARY_DIR="${CUOPT_RUNTIME_LIBRARY_DIR}" \ + `# Passed unconditionally, including empty: omitting them would leave a previous run's` \ + `# values in CMakeCache.txt and silently link a stale libcuopt with mismatched headers.` \ + -DCUOPT_LIBRARY="${CUOPT_LIBRARY}" \ + -DCUOPT_EXTRA_INCLUDE_DIRS="${CUOPT_EXTRA_INCLUDE_DIRS}" \ + ${CXX_COMPILER:+-DCMAKE_CXX_COMPILER="${CXX_COMPILER}"} \ + -DJAVA_HOME="${JAVA_HOME}" + +env -u CFLAGS -u CXXFLAGS -u CPPFLAGS -u LDFLAGS \ + "${CMAKE}" --build "${BUILD_DIR}" --target cuopt_jni \ + --parallel "${PARALLEL_LEVEL:-2}" + +echo "Built ${BUILD_DIR}/libcuopt_jni.so" + +# Fail fast on a Java native declaration and its JNI entry point drifting apart. The compiler +# cannot catch that, and JNI resolves methods lazily, so the library still loads and the failure +# only appears when something calls the method. Set CUOPT_SKIP_JNI_SYMBOL_CHECK=1 to skip it while +# iterating, for instance after adding a native declaration but before writing its entry point. +if [[ "${CUOPT_SKIP_JNI_SYMBOL_CHECK:-0}" == "1" ]]; then + echo "Skipping the JNI symbol check (CUOPT_SKIP_JNI_SYMBOL_CHECK=1)." +else + CUOPT_JAVA_NATIVE_BUILD_DIR="${BUILD_DIR}" bash "${MODULE_DIR}/scripts/check_jni_symbols.sh" +fi diff --git a/java/cuopt/scripts/check_jni_symbols.sh b/java/cuopt/scripts/check_jni_symbols.sh new file mode 100755 index 0000000000..30f16c0f83 --- /dev/null +++ b/java/cuopt/scripts/check_jni_symbols.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Check that the JNI layer and the Java `native` declarations agree. +# +# The bindings are hand-written: every `static native` method in Java needs a matching +# Java_com_nvidia_..._name function in cuopt_jni.cpp. Nothing in the compiler enforces that +# pairing, and a mismatch is invisible until a test (or a user) touches the method and gets an +# UnsatisfiedLinkError. This compares what javac says the symbols must be against what the built +# library actually exports. + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "${MODULE_DIR}/../.." && pwd)" +NATIVE_BUILD_DIR="${CUOPT_JAVA_NATIVE_BUILD_DIR:-${MODULE_DIR}/build/native}" +LIBRARY="${NATIVE_BUILD_DIR}/libcuopt_jni.so" + +source "${MODULE_DIR}/scripts/java_home.sh" +cuopt_java_setup_home javac + +if [[ ! -f "${LIBRARY}" ]]; then + echo "libcuopt_jni.so was not found at ${LIBRARY}; build it first." >&2 + exit 1 +fi +if ! command -v nm > /dev/null; then + echo "nm was not found; skipping the JNI symbol check." >&2 + exit 0 +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT + +# Regenerate the constants so javac sees the same sources the real build compiles. +bash "${MODULE_DIR}/scripts/generate_constants.sh" \ + "${REPO_ROOT}/cpp/include/cuopt/mathematical_optimization/constants.h" \ + "${WORK_DIR}/generated" > /dev/null + +mapfile -t SOURCES < <(find "${MODULE_DIR}/src/main/java" "${WORK_DIR}/generated" -name '*.java') +"${JAVA_HOME}/bin/javac" -nowarn -d "${WORK_DIR}/classes" -h "${WORK_DIR}/headers" "${SOURCES[@]}" + +# javac emits one prototype per native method; the built library must define exactly those. +grep -ho 'Java_com_nvidia[A-Za-z0-9_]*' "${WORK_DIR}"/headers/*.h | sort -u > "${WORK_DIR}/declared" +nm -D --defined-only "${LIBRARY}" \ + | grep -o 'Java_com_nvidia[A-Za-z0-9_]*' | sort -u > "${WORK_DIR}/defined" + +missing="$(comm -23 "${WORK_DIR}/declared" "${WORK_DIR}/defined")" +orphaned="$(comm -13 "${WORK_DIR}/declared" "${WORK_DIR}/defined")" + +status=0 +if [[ -n "${missing}" ]]; then + echo "Java declares native methods that libcuopt_jni.so does not define." >&2 + echo "Calling any of these throws UnsatisfiedLinkError at run time:" >&2 + echo "${missing}" | sed 's/^/ /' >&2 + status=1 +fi +if [[ -n "${orphaned}" ]]; then + echo "libcuopt_jni.so defines JNI entry points with no Java declaration." >&2 + echo "These are dead code, or a rename left one side behind:" >&2 + echo "${orphaned}" | sed 's/^/ /' >&2 + status=1 +fi + +if [[ "${status}" -ne 0 ]]; then + exit "${status}" +fi + +echo "JNI symbols match: $(wc -l < "${WORK_DIR}/declared") native methods declared and defined." diff --git a/java/cuopt/scripts/generate_constants.sh b/java/cuopt/scripts/generate_constants.sh new file mode 100755 index 0000000000..cdfe34a9a6 --- /dev/null +++ b/java/cuopt/scripts/generate_constants.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +HEADER=${1:?missing constants.h path} +OUT_DIR=${2:?missing output directory} +PACKAGE_DIR="${OUT_DIR}/com/nvidia/cuopt/mathematicalprogramming" +OUT_FILE="${PACKAGE_DIR}/CuOptConstants.java" + +mkdir -p "${PACKAGE_DIR}" + +{ + echo "/*" + echo " * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved." + echo " * SPDX-License-Identifier: Apache-2.0" + echo " *" + echo " * AUTO-GENERATED FILE. DO NOT EDIT." + echo " * Generated from cpp/include/cuopt/mathematical_optimization/constants.h." + echo " * Run 'mvn generate-sources' from java/cuopt to regenerate." + echo " */" + echo "package com.nvidia.cuopt.mathematicalprogramming;" + echo + echo "public final class CuOptConstants {" + echo " private CuOptConstants() {}" + echo + awk ' + /^#define CUOPT_/ { + name = $2 + value = $3 + if (name ~ /CUOPT_INFINITY/) next + if (value ~ /^[-]?[0-9]+$/) { + printf(" public static final int %s = %s;%s", name, value, "\n") + } else if (value ~ /^'\''.'\''$/) { + printf(" public static final byte %s = %s;%s", name, value, "\n") + } else if (value ~ /^".*"$/) { + printf(" public static final String %s = %s;%s", name, value, "\n") + } + } + ' "${HEADER}" + echo "}" +} > "${OUT_FILE}" diff --git a/java/cuopt/scripts/java_home.sh b/java/cuopt/scripts/java_home.sh new file mode 100644 index 0000000000..85f5940ee5 --- /dev/null +++ b/java/cuopt/scripts/java_home.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +cuopt_java_setup_home() { + local required_binary="${1:-javac}" + + if [[ -z "${JAVA_HOME:-}" ]]; then + local javac_path + javac_path="$(command -v javac || true)" + if [[ -n "${javac_path}" ]]; then + JAVA_HOME="$(dirname "$(dirname "$(readlink -f "${javac_path}")")")" + export JAVA_HOME + fi + fi + + if [[ ! -x "${JAVA_HOME:-}/bin/${required_binary}" ]]; then + echo "JAVA_HOME must point to a JDK containing bin/${required_binary} (Java 11 is required)." >&2 + exit 1 + fi +} diff --git a/java/cuopt/scripts/test.sh b/java/cuopt/scripts/test.sh new file mode 100644 index 0000000000..6764408faa --- /dev/null +++ b/java/cuopt/scripts/test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "${MODULE_DIR}/../.." && pwd)" +CUOPT_PREFIX="${CUOPT_PREFIX:-${CONDA_PREFIX:-${REPO_ROOT}/.cuopt_env}}" +NATIVE_BUILD_DIR="${CUOPT_JAVA_NATIVE_BUILD_DIR:-${MODULE_DIR}/build/native}" +export CUOPT_PREFIX CUOPT_JAVA_NATIVE_BUILD_DIR="${NATIVE_BUILD_DIR}" + +source "${MODULE_DIR}/scripts/java_home.sh" +cuopt_java_setup_home javac + +bash "${MODULE_DIR}/scripts/build_native.sh" + +cuopt_java_setup_home java + +existing_ld_library_path="${LD_LIBRARY_PATH:-}" +CUDA_RUNTIME_DIR="$(find "${CUOPT_PREFIX}/targets" -path "*/lib/libcudart.so" -print -quit 2>/dev/null || true)" +CUDA_RUNTIME_DIR="${CUDA_RUNTIME_DIR%/libcudart.so}" +library_path="${CUOPT_PREFIX}/lib:${NATIVE_BUILD_DIR}" +if [[ -d "${CUDA_RUNTIME_DIR}" ]]; then + library_path="${CUDA_RUNTIME_DIR}:${library_path}" +fi +if [[ -n "${CUOPT_EXTRA_LIBRARY_DIRS:-}" ]]; then + library_path="${CUOPT_EXTRA_LIBRARY_DIRS}:${library_path}" +fi +export LD_LIBRARY_PATH="${library_path}${existing_ld_library_path:+:${existing_ld_library_path}}" + +# When CPM fetches its own rmm (because the conda prefix has a different version), libcuopt is +# compiled against that copy but records the conda lib dir ahead of the _deps directories in its +# RPATH. DT_RPATH is searched before LD_LIBRARY_PATH, so the loader would pick up the conda +# librmm and fail on rmm's version-tagged inline namespace. Preloading the matching library is +# the only way to win that lookup without relinking libcuopt. +preload="${LD_PRELOAD:-}" +for candidate in ${CUOPT_PRELOAD_LIBS:-}; do + if [[ -f "${candidate}" ]]; then + preload="${candidate}${preload:+:${preload}}" + fi +done +if [[ -n "${preload}" ]]; then + export LD_PRELOAD="${preload}" +fi + +cd "${MODULE_DIR}" +mvn verify \ + -Dcuopt.native.dir="${NATIVE_BUILD_DIR}" \ + "$@" diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/CSRMatrix.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/CSRMatrix.java new file mode 100644 index 0000000000..f05842481e --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/CSRMatrix.java @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.util.Arrays; + +public final class CSRMatrix { + private final int[] rowOffsets; + private final int[] columnIndices; + private final double[] values; + + /** Construct a CSR matrix using the cuOpt values, indices, offsets argument order. */ + public CSRMatrix(double[] values, int[] columnIndices, int[] rowOffsets) { + validate(values, columnIndices, rowOffsets); + this.rowOffsets = Arrays.copyOf(rowOffsets, rowOffsets.length); + this.columnIndices = Arrays.copyOf(columnIndices, columnIndices.length); + this.values = Arrays.copyOf(values, values.length); + } + + public int[] getRowOffsets() { + return Arrays.copyOf(rowOffsets, rowOffsets.length); + } + + public int[] getColumnIndices() { + return Arrays.copyOf(columnIndices, columnIndices.length); + } + + public double[] getValues() { + return Arrays.copyOf(values, values.length); + } + + int[] rowOffsetsUnsafe() { + return rowOffsets; + } + + int[] columnIndicesUnsafe() { + return columnIndices; + } + + double[] valuesUnsafe() { + return values; + } + + private static void validate(double[] values, int[] columnIndices, int[] rowOffsets) { + if (values == null) { + throw new IllegalArgumentException("CSR values must not be null"); + } + if (columnIndices == null) { + throw new IllegalArgumentException("CSR column indices must not be null"); + } + if (rowOffsets == null) { + throw new IllegalArgumentException("CSR row offsets must not be null"); + } + if (values.length != columnIndices.length) { + throw new IllegalArgumentException("CSR values and column indices must have the same length"); + } + if (rowOffsets.length == 0) { + throw new IllegalArgumentException("CSR row offsets must not be empty"); + } + if (rowOffsets[0] != 0) { + throw new IllegalArgumentException("CSR row offsets must start at 0"); + } + for (int i = 1; i < rowOffsets.length; i++) { + if (rowOffsets[i] < rowOffsets[i - 1]) { + throw new IllegalArgumentException("CSR row offsets must be monotonic"); + } + } + if (rowOffsets[rowOffsets.length - 1] != values.length) { + throw new IllegalArgumentException("CSR row offsets must end at the number of values"); + } + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Constraint.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Constraint.java new file mode 100644 index 0000000000..6e3a667a91 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Constraint.java @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.util.Map; + +public final class Constraint { + private int index = -1; + private LinearExpression linearExpression; + private final QuadraticExpression quadraticExpression; + private final ConstraintSense sense; + private double rhs; + private String name = ""; + private double slack = Double.NaN; + private double dualValue = Double.NaN; + + Constraint(LinearExpression expression, ConstraintSense sense, double rhs) { + this.linearExpression = expression; + this.quadraticExpression = null; + this.sense = sense; + this.rhs = rhs - expression.getConstant(); + } + + Constraint(QuadraticExpression expression, ConstraintSense sense, double rhs) { + this.linearExpression = expression.getLinearExpression(); + this.quadraticExpression = expression; + this.sense = sense; + this.rhs = rhs - expression.getLinearExpression().getConstant(); + } + + public String getConstraintName() { + return name; + } + + public int getIndex() { + return index; + } + + void setIndex(int index) { + this.index = index; + } + + Constraint setConstraintName(String name) { + this.name = name == null ? "" : name; + return this; + } + + public ConstraintSense getSense() { + return sense; + } + + public double getRHS() { + return rhs; + } + + Constraint updateLinearExpression(LinearExpression expression) { + this.linearExpression = expression; + return this; + } + + Constraint updateRHS(double rhs) { + this.rhs = rhs; + return this; + } + + public double getCoefficient(Variable variable) { + return linearExpression.getTerms().getOrDefault(variable, 0.0); + } + + public LinearExpression getLinearExpression() { + return linearExpression; + } + + public boolean isQuadratic() { + return quadraticExpression != null && !quadraticExpression.getQuadraticTerms().isEmpty(); + } + + public QuadraticExpression getQuadraticExpression() { + return quadraticExpression; + } + + double computeSlack() { + double lhs = 0.0; + for (Map.Entry entry : linearExpression.getTerms().entrySet()) { + lhs += entry.getValue() * entry.getKey().getValue(); + } + if (isQuadratic()) { + for (QuadraticExpression.QuadraticTerm term : quadraticExpression.getQuadraticTerms()) { + lhs += + term.getCoefficient() + * term.getFirst().getValue() + * term.getSecond().getValue(); + } + } + // Match Python's Constraint.compute_slack contract: RHS minus the evaluated LHS for + // every row sense. This intentionally keeps the sign convention stable for GE rows too. + return rhs - lhs; + } + + public double getSlack() { + return slack; + } + + void setSlack(double slack) { + this.slack = slack; + } + + public double getDualValue() { + return dualValue; + } + + void setDualValue(double dualValue) { + this.dualValue = dualValue; + } + + void resetSolvedValues() { + slack = Double.NaN; + dualValue = Double.NaN; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ConstraintSense.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ConstraintSense.java new file mode 100644 index 0000000000..7c585b10c6 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ConstraintSense.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +public enum ConstraintSense { + LE((byte) 'L'), + GE((byte) 'G'), + EQ((byte) 'E'); + + private final byte nativeValue; + + ConstraintSense(byte nativeValue) { + this.nativeValue = nativeValue; + } + + byte nativeValue() { + return nativeValue; + } + + static ConstraintSense fromNative(byte value) { + for (ConstraintSense sense : values()) { + if (sense.nativeValue == value) { + return sense; + } + } + throw new IllegalArgumentException("Unknown constraint sense: " + (char) value); + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/CuOptException.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/CuOptException.java new file mode 100644 index 0000000000..6d0eed37f7 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/CuOptException.java @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +public class CuOptException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final int statusCode; + + public CuOptException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/LPStats.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/LPStats.java new file mode 100644 index 0000000000..18090a646e --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/LPStats.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +public final class LPStats { + private final double primalResidual; + private final double dualResidual; + private final double gap; + private final int numIterations; + private final SolverMethod solvedBy; + + LPStats(double[] values) { + this.primalResidual = values[0]; + this.dualResidual = values[1]; + this.gap = values[2]; + this.numIterations = (int) values[3]; + this.solvedBy = SolverMethod.fromNative((int) values[4]); + } + + public double getPrimalResidual() { + return primalResidual; + } + + public double getDualResidual() { + return dualResidual; + } + + public double getGap() { + return gap; + } + + public int getNumIterations() { + return numIterations; + } + + public SolverMethod getSolvedBy() { + return solvedBy; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/LinearExpression.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/LinearExpression.java new file mode 100644 index 0000000000..63ce8ed3ea --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/LinearExpression.java @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class LinearExpression { + private final LinkedHashMap terms; + private final double constant; + + public LinearExpression() { + this(new LinkedHashMap<>(), 0.0); + } + + private LinearExpression(LinkedHashMap terms, double constant) { + this.terms = terms; + this.constant = constant; + } + + public static LinearExpression of(Variable variable) { + return of(variable, 1.0); + } + + public static LinearExpression of(Variable variable, double coefficient) { + return new LinearExpression().plus(variable, coefficient); + } + + public static LinearExpression ofConstant(double constant) { + return new LinearExpression(new LinkedHashMap<>(), constant); + } + + public LinearExpression plus(Variable variable) { + return plus(variable, 1.0); + } + + public LinearExpression plus(Variable variable, double coefficient) { + LinkedHashMap copy = new LinkedHashMap<>(terms); + copy.merge(variable, coefficient, Double::sum); + return new LinearExpression(copy, constant); + } + + public LinearExpression plus(LinearExpression other) { + LinkedHashMap copy = new LinkedHashMap<>(terms); + for (Map.Entry entry : other.terms.entrySet()) { + copy.merge(entry.getKey(), entry.getValue(), Double::sum); + } + return new LinearExpression(copy, constant + other.constant); + } + + public QuadraticExpression plus(QuadraticExpression other) { + return other.plus(this); + } + + public LinearExpression constant(double additionalConstant) { + return new LinearExpression(new LinkedHashMap<>(terms), constant + additionalConstant); + } + + public LinearExpression plus(double value) { + return constant(value); + } + + public LinearExpression minus(double value) { + return constant(-value); + } + + public LinearExpression minus(Variable variable) { + return plus(variable, -1.0); + } + + public LinearExpression minus(Variable variable, double coefficient) { + return plus(variable, -coefficient); + } + + public LinearExpression minus(LinearExpression other) { + return plus(other.times(-1.0)); + } + + public QuadraticExpression minus(QuadraticExpression other) { + return other.times(-1.0).plus(this); + } + + public LinearExpression times(double scalar) { + LinkedHashMap copy = new LinkedHashMap<>(); + for (Map.Entry entry : terms.entrySet()) { + copy.put(entry.getKey(), entry.getValue() * scalar); + } + return new LinearExpression(copy, constant * scalar); + } + + public LinearExpression dividedBy(double scalar) { + if (scalar == 0.0) { + throw new IllegalArgumentException("Cannot divide a linear expression by zero"); + } + return times(1.0 / scalar); + } + + public Map getVariablesAndCoefficients() { + return getTerms(); + } + + public Constraint le(double rhs) { + return new Constraint(this, ConstraintSense.LE, rhs); + } + + public Constraint le(Variable variable) { + return minus(variable).le(0.0); + } + + public Constraint le(LinearExpression expression) { + return minus(expression).le(0.0); + } + + public Constraint le(QuadraticExpression expression) { + return expression.times(-1.0).plus(this).le(0.0); + } + + public Constraint ge(double rhs) { + return new Constraint(this, ConstraintSense.GE, rhs); + } + + public Constraint ge(Variable variable) { + return minus(variable).ge(0.0); + } + + public Constraint ge(LinearExpression expression) { + return minus(expression).ge(0.0); + } + + public Constraint ge(QuadraticExpression expression) { + return expression.times(-1.0).plus(this).ge(0.0); + } + + public Constraint eq(double rhs) { + return new Constraint(this, ConstraintSense.EQ, rhs); + } + + public Constraint eq(Variable variable) { + return minus(variable).eq(0.0); + } + + public Constraint eq(LinearExpression expression) { + return minus(expression).eq(0.0); + } + + public Map getTerms() { + return Collections.unmodifiableMap(terms); + } + + public List getVariables() { + return List.copyOf(terms.keySet()); + } + + public Variable getVariable(int index) { + return new ArrayList<>(terms.keySet()).get(index); + } + + public List getCoefficients() { + return List.copyOf(terms.values()); + } + + public double getCoefficient(int index) { + return new ArrayList<>(terms.values()).get(index); + } + + public double getCoefficient(Variable variable) { + return terms.getOrDefault(variable, 0.0); + } + + public double getConstant() { + return constant; + } + + public double getValue() { + double value = constant; + for (Map.Entry entry : terms.entrySet()) { + value += entry.getValue() * entry.getKey().getValue(); + } + return value; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPCallback.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPCallback.java new file mode 100644 index 0000000000..6155f6aeaa --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPCallback.java @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +/** Marker interface for callbacks registered with MIP solver settings. */ +public interface MIPCallback {} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPCallbackSolution.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPCallbackSolution.java new file mode 100644 index 0000000000..258e02053a --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPCallbackSolution.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.util.Arrays; + +public final class MIPCallbackSolution { + final double[] solution; + final double objectiveValue; + + public MIPCallbackSolution(double[] solution, double objectiveValue) { + this.solution = Arrays.copyOf(solution, solution.length); + this.objectiveValue = objectiveValue; + } + + public double[] getSolution() { + return Arrays.copyOf(solution, solution.length); + } + + public double getObjectiveValue() { + return objectiveValue; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPSetSolutionCallback.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPSetSolutionCallback.java new file mode 100644 index 0000000000..2e2a010d2b --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPSetSolutionCallback.java @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +@FunctionalInterface +public interface MIPSetSolutionCallback extends MIPCallback { + MIPCallbackSolution getSolution(double solutionBound, Object userData); +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPSolutionCallback.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPSolutionCallback.java new file mode 100644 index 0000000000..859d745887 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPSolutionCallback.java @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +@FunctionalInterface +public interface MIPSolutionCallback extends MIPCallback { + void onSolution(double[] solution, double objectiveValue, double solutionBound, Object userData); +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPStats.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPStats.java new file mode 100644 index 0000000000..49c4d4d660 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/MIPStats.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +public final class MIPStats { + private final double presolveTime; + private final double maxConstraintViolation; + private final double maxIntViolation; + private final double maxVariableBoundViolation; + private final int numNodes; + private final int numSimplexIterations; + + MIPStats(double[] values) { + this.presolveTime = values[0]; + this.maxConstraintViolation = values[1]; + this.maxIntViolation = values[2]; + this.maxVariableBoundViolation = values[3]; + this.numNodes = (int) values[4]; + this.numSimplexIterations = (int) values[5]; + } + + public double getPresolveTime() { + return presolveTime; + } + + public double getMaxConstraintViolation() { + return maxConstraintViolation; + } + + public double getMaxIntViolation() { + return maxIntViolation; + } + + public double getMaxVariableBoundViolation() { + return maxVariableBoundViolation; + } + + public int getNumNodes() { + return numNodes; + } + + public int getNumSimplexIterations() { + return numSimplexIterations; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/NativeCuOpt.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/NativeCuOpt.java new file mode 100644 index 0000000000..2fb20c9c20 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/NativeCuOpt.java @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.nio.file.Path; + +final class NativeCuOpt { + static { + String nativeDir = System.getProperty("cuopt.native.dir"); + if (nativeDir == null || nativeDir.isBlank()) { + System.loadLibrary("cuopt_jni"); + } else { + System.load(Path.of(nativeDir, System.mapLibraryName("cuopt_jni")).toAbsolutePath().toString()); + } + } + + private NativeCuOpt() {} + + static native int getFloatSize(); + static native long readProblemWithFormat(String path, boolean fixedMPSFormat); + + static native long createSolverSettings(); + static native void destroySolverSettings(long handle); + static native void setSetting(long handle, String name, String value); + static native void setIntegerSetting(long handle, String name, int value); + static native void setFloatSetting(long handle, String name, double value); + static native String getSetting(long handle, String name); + static native void addMIPStart(long handle, double[] values); + static native void setInitialPrimalSolution(long handle, double[] values); + static native void setInitialDualSolution(long handle, double[] values); + static native void registerMIPGetSolutionCallback( + long handle, MIPSolutionCallback callback, Object userData, int numVariables); + static native void registerMIPSetSolutionCallback( + long handle, MIPSetSolutionCallback callback, Object userData, int numVariables); + static native long createProblem( + int numConstraints, + int numVariables, + int objectiveSense, + double objectiveOffset, + double[] objectiveCoefficients, + int[] rowOffsets, + int[] columnIndices, + double[] values, + byte[] constraintSense, + double[] rhs, + double[] lowerBounds, + double[] upperBounds, + byte[] variableTypes); + + static native void writeProblem(long handle, String path); + static native void destroyProblem(long handle); + static native void setQuadraticObjective(long handle, int[] rows, int[] columns, double[] values); + static native void addQuadraticConstraint( + long handle, + int[] rows, + int[] columns, + double[] values, + int[] linearIndices, + double[] linearCoefficients, + byte sense, + double rhs); + + static native int getNumVariables(long handle); + static native int getNumConstraints(long handle); + static native int getNumNonZeros(long handle); + static native int getObjectiveSense(long handle); + static native double getObjectiveOffset(long handle); + static native double[] getObjectiveCoefficients(long handle); + static native Object[] getConstraintMatrix(long handle); + static native byte[] getConstraintSense(long handle); + static native double[] getConstraintRHS(long handle); + static native double[] getConstraintLowerBounds(long handle); + static native double[] getConstraintUpperBounds(long handle); + static native double[] getVariableLowerBounds(long handle); + static native double[] getVariableUpperBounds(long handle); + static native byte[] getVariableTypes(long handle); + static native void setVariableNames(long handle, String[] values); + static native void setRowNames(long handle, String[] values); + static native void setProblemName(long handle, String value); + static native double[] getQuadraticObjectiveValues(long handle); + static native int[] getQuadraticObjectiveIndices(long handle); + static native int[] getQuadraticObjectiveOffsets(long handle); + static native String[] getVariableNames(long handle); + static native String[] getRowNames(long handle); + static native String getProblemName(long handle); + static native int getProblemCategory(long handle); + static native Object[] getQuadraticConstraints(long handle); + static native long solve(long problemHandle, long settingsHandle); + + static native void destroySolution(long handle); + static native int getTerminationStatus(long handle); + static native int getErrorStatus(long handle); + static native String getErrorString(long handle); + static native double[] getPrimalSolution(long handle, int size); + static native int getDualSolutionSize(long handle); + static native double[] getDualSolution(long handle, int size); + static native double[] getReducedCosts(long handle, int size); + static native double getObjectiveValue(long handle); + static native double getDualObjectiveValue(long handle); + static native double getSolveTime(long handle); + static native double getMIPGap(long handle); + static native double getSolutionBound(long handle); + static native double[] getLPStats(long handle); + static native double[] getMIPStats(long handle); +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/NativeProblem.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/NativeProblem.java new file mode 100644 index 0000000000..c55d616324 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/NativeProblem.java @@ -0,0 +1,303 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.lang.ref.Cleaner; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class NativeProblem implements AutoCloseable { + private static final Cleaner CLEANER = Cleaner.create(); + private final NativeHandle nativeHandle; + private final Cleaner.Cleanable cleanable; + private final Map quadraticConstraintNames = new HashMap<>(); + + private NativeProblem(long handle) { + this.nativeHandle = new NativeHandle(handle); + this.cleanable = CLEANER.register(this, nativeHandle); + } + + static NativeProblem createProblem( + int numConstraints, + int numVariables, + ObjectiveSense objectiveSense, + double objectiveOffset, + double[] objectiveCoefficients, + CSRMatrix constraintMatrix, + byte[] constraintSense, + double[] rhs, + double[] variableLowerBounds, + double[] variableUpperBounds, + byte[] variableTypes) { + long handle = + NativeCuOpt.createProblem( + numConstraints, + numVariables, + objectiveSense.nativeValue(), + objectiveOffset, + Arrays.copyOf(objectiveCoefficients, objectiveCoefficients.length), + constraintMatrix.getRowOffsets(), + constraintMatrix.getColumnIndices(), + constraintMatrix.getValues(), + Arrays.copyOf(constraintSense, constraintSense.length), + Arrays.copyOf(rhs, rhs.length), + Arrays.copyOf(variableLowerBounds, variableLowerBounds.length), + Arrays.copyOf(variableUpperBounds, variableUpperBounds.length), + Arrays.copyOf(variableTypes, variableTypes.length)); + return new NativeProblem(handle); + } + + static NativeProblem read(String path, boolean fixedMPSFormat) { + return new NativeProblem(NativeCuOpt.readProblemWithFormat(path, fixedMPSFormat)); + } + + long handle() { + nativeHandle.requireOpen(); + return nativeHandle.handle; + } + + int getNumVariables() { + return NativeCuOpt.getNumVariables(handle()); + } + + int getNumConstraints() { + return NativeCuOpt.getNumConstraints(handle()); + } + + int getNumNonZeros() { + return NativeCuOpt.getNumNonZeros(handle()); + } + + ObjectiveSense getObjectiveSense() { + return NativeCuOpt.getObjectiveSense(handle()) == ObjectiveSense.MAXIMIZE.nativeValue() + ? ObjectiveSense.MAXIMIZE + : ObjectiveSense.MINIMIZE; + } + + double getObjectiveOffset() { + return NativeCuOpt.getObjectiveOffset(handle()); + } + + double[] getObjectiveCoefficients() { + return NativeCuOpt.getObjectiveCoefficients(handle()); + } + + CSRMatrix getConstraintMatrix() { + Object[] matrix = NativeCuOpt.getConstraintMatrix(handle()); + return new CSRMatrix((double[]) matrix[2], (int[]) matrix[1], (int[]) matrix[0]); + } + + byte[] getConstraintSense() { + return NativeCuOpt.getConstraintSense(handle()); + } + + double[] getConstraintRHS() { + return NativeCuOpt.getConstraintRHS(handle()); + } + + double[] getConstraintLowerBounds() { + return NativeCuOpt.getConstraintLowerBounds(handle()); + } + + double[] getConstraintUpperBounds() { + return NativeCuOpt.getConstraintUpperBounds(handle()); + } + + double[] getVariableLowerBounds() { + return NativeCuOpt.getVariableLowerBounds(handle()); + } + + double[] getVariableUpperBounds() { + return NativeCuOpt.getVariableUpperBounds(handle()); + } + + byte[] getVariableTypes() { + return NativeCuOpt.getVariableTypes(handle()); + } + + NativeProblem setVariableNames(String[] variableNames) { + NativeCuOpt.setVariableNames(handle(), variableNames == null ? new String[0] : variableNames.clone()); + return this; + } + + NativeProblem setRowNames(String[] rowNames) { + NativeCuOpt.setRowNames(handle(), rowNames == null ? new String[0] : rowNames.clone()); + return this; + } + + NativeProblem setProblemName(String problemName) { + NativeCuOpt.setProblemName(handle(), problemName == null ? "" : problemName); + return this; + } + + double[] getQuadraticObjectiveValues() { + return NativeCuOpt.getQuadraticObjectiveValues(handle()); + } + + int[] getQuadraticObjectiveIndices() { + return NativeCuOpt.getQuadraticObjectiveIndices(handle()); + } + + int[] getQuadraticObjectiveOffsets() { + return NativeCuOpt.getQuadraticObjectiveOffsets(handle()); + } + + String[] getVariableNames() { + return NativeCuOpt.getVariableNames(handle()); + } + + String[] getRowNames() { + return NativeCuOpt.getRowNames(handle()); + } + + String getProblemName() { + return NativeCuOpt.getProblemName(handle()); + } + + ProblemCategory getProblemCategory() { + return ProblemCategory.fromNative(NativeCuOpt.getProblemCategory(handle())); + } + + NativeProblem setQuadraticObjective(QuadraticExpression expression) { + NativeCuOpt.setQuadraticObjective( + handle(), quadraticRows(expression), quadraticColumns(expression), quadraticValues(expression)); + return this; + } + + NativeProblem addQuadraticConstraint(Constraint constraint) { + if (!constraint.isQuadratic()) { + throw new IllegalArgumentException("Quadratic constraint requires quadratic terms"); + } + if (constraint.getSense() == ConstraintSense.EQ) { + throw new IllegalArgumentException("Equality quadratic constraints are not supported"); + } + QuadraticExpression expression = constraint.getQuadraticExpression(); + LinearExpression linear = constraint.getLinearExpression(); + int[] linearIndices = new int[linear.getTerms().size()]; + double[] linearCoefficients = new double[linear.getTerms().size()]; + int i = 0; + for (var entry : linear.getTerms().entrySet()) { + linearIndices[i] = entry.getKey().getIndex(); + linearCoefficients[i] = entry.getValue(); + i++; + } + int rowIndex = getNumConstraints(); + NativeCuOpt.addQuadraticConstraint( + handle(), + quadraticRows(expression), + quadraticColumns(expression), + quadraticValues(expression), + linearIndices, + linearCoefficients, + constraint.getSense().nativeValue(), + constraint.getRHS()); + quadraticConstraintNames.put(rowIndex, constraint.getConstraintName()); + return this; + } + + List getQuadraticConstraints() { + Object[] nativeConstraints = NativeCuOpt.getQuadraticConstraints(handle()); + List result = new ArrayList<>(nativeConstraints.length); + for (int i = 0; i < nativeConstraints.length; i++) { + Object[] entry = (Object[]) nativeConstraints[i]; + int rowIndex = ((int[]) entry[0])[0]; + String rowName = (String) entry[1]; + String addedName = quadraticConstraintNames.get(rowIndex); + if (addedName != null && !addedName.isEmpty()) { + rowName = addedName; + } + ConstraintSense sense = ConstraintSense.fromNative(((byte[]) entry[2])[0]); + double rhs = ((double[]) entry[5])[0]; + result.add( + new QuadraticConstraint( + rowIndex, + rowName, + sense, + (double[]) entry[3], + (int[]) entry[4], + rhs, + (int[]) entry[6], + (int[]) entry[7], + (double[]) entry[8])); + } + return List.copyOf(result); + } + + Solution solve(SolverSettings settings) { + SolverSettings actualSettings = settings == null ? new SolverSettings() : settings; + boolean closeSettings = settings == null; + try { + long solutionHandle = NativeCuOpt.solve(handle(), actualSettings.handle()); + return new Solution( + solutionHandle, + getNumVariables(), + getNumConstraints(), + getProblemCategory(), + getVariableNames()); + } finally { + if (closeSettings) { + actualSettings.close(); + } + } + } + + void write(String path) { + NativeCuOpt.writeProblem(handle(), path); + } + + @Override + public void close() { + cleanable.clean(); + } + + private static int[] quadraticRows(QuadraticExpression expression) { + int[] rows = new int[expression.getQuadraticTerms().size()]; + for (int i = 0; i < rows.length; i++) { + rows[i] = expression.getQuadraticTerms().get(i).getFirst().getIndex(); + } + return rows; + } + + private static int[] quadraticColumns(QuadraticExpression expression) { + int[] columns = new int[expression.getQuadraticTerms().size()]; + for (int i = 0; i < columns.length; i++) { + columns[i] = expression.getQuadraticTerms().get(i).getSecond().getIndex(); + } + return columns; + } + + private static double[] quadraticValues(QuadraticExpression expression) { + double[] values = new double[expression.getQuadraticTerms().size()]; + for (int i = 0; i < values.length; i++) { + values[i] = expression.getQuadraticTerms().get(i).getCoefficient(); + } + return values; + } + + private static final class NativeHandle implements Runnable { + private long handle; + + NativeHandle(long handle) { + this.handle = handle; + } + + void requireOpen() { + if (handle == 0) { + throw new IllegalStateException("Native problem is closed"); + } + } + + @Override + public void run() { + if (handle != 0) { + NativeCuOpt.destroyProblem(handle); + handle = 0; + } + } + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ObjectiveSense.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ObjectiveSense.java new file mode 100644 index 0000000000..b4161426f3 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ObjectiveSense.java @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +public enum ObjectiveSense { + MAXIMIZE(-1), + MINIMIZE(1); + + private final int nativeValue; + + ObjectiveSense(int nativeValue) { + this.nativeValue = nativeValue; + } + + int nativeValue() { + return nativeValue; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/PDLPSolverMode.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/PDLPSolverMode.java new file mode 100644 index 0000000000..e0bf9a771e --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/PDLPSolverMode.java @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +/** PDLP solver modes backed by constants generated from the C++ public header. */ +public enum PDLPSolverMode { + STABLE1(CuOptConstants.CUOPT_PDLP_SOLVER_MODE_STABLE1), + STABLE2(CuOptConstants.CUOPT_PDLP_SOLVER_MODE_STABLE2), + METHODICAL1(CuOptConstants.CUOPT_PDLP_SOLVER_MODE_METHODICAL1), + FAST1(CuOptConstants.CUOPT_PDLP_SOLVER_MODE_FAST1), + STABLE3(CuOptConstants.CUOPT_PDLP_SOLVER_MODE_STABLE3); + + private final int nativeValue; + + PDLPSolverMode(int nativeValue) { + this.nativeValue = nativeValue; + } + + public int nativeValue() { + return nativeValue; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Problem.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Problem.java new file mode 100644 index 0000000000..a9f668c6bf --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Problem.java @@ -0,0 +1,607 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +public final class Problem implements AutoCloseable { + private final String name; + private final List variables = new ArrayList<>(); + private final List constraints = new ArrayList<>(); + private LinearExpression linearObjective = new LinearExpression(); + private QuadraticExpression quadraticObjective = null; + private ObjectiveSense objectiveSense = ObjectiveSense.MINIMIZE; + private boolean objectiveSet = false; + private TerminationStatus status = TerminationStatus.NO_TERMINATION; + private double objectiveValue = Double.NaN; + private double solveTime = Double.NaN; + + public Problem() { + this(""); + } + + public Problem(String name) { + this.name = name == null ? "" : name; + } + + public String getName() { + return name; + } + + public Variable addVariable() { + return addVariable(0.0, Double.POSITIVE_INFINITY, 0.0, VariableType.CONTINUOUS, ""); + } + + public Variable addVariable( + double lowerBound, + double upperBound, + double objectiveCoefficient, + VariableType variableType, + String name) { + Variable variable = + new Variable( + variables.size(), lowerBound, upperBound, objectiveCoefficient, variableType, name); + variables.add(variable); + resetSolvedValues(); + return variable; + } + + public Constraint addConstraint(Constraint constraint) { + return addConstraint(constraint, ""); + } + + public Constraint addConstraint(Constraint constraint, String name) { + constraint.setConstraintName(name); + constraint.setIndex(constraints.size()); + constraints.add(constraint); + resetSolvedValues(); + return constraint; + } + + public Problem setObjective(LinearExpression expression, ObjectiveSense sense) { + this.linearObjective = expression; + this.quadraticObjective = null; + this.objectiveSense = sense; + this.objectiveSet = true; + syncVariableObjectiveCoefficients(expression); + resetSolvedValues(); + return this; + } + + public Problem setObjective(Variable variable, ObjectiveSense sense) { + return setObjective(LinearExpression.of(variable), sense); + } + + public Problem setObjective(double constant, ObjectiveSense sense) { + return setObjective(LinearExpression.ofConstant(constant), sense); + } + + public Problem setObjective(QuadraticExpression expression, ObjectiveSense sense) { + this.linearObjective = expression.getLinearExpression(); + this.quadraticObjective = expression; + this.objectiveSense = sense; + this.objectiveSet = true; + syncVariableObjectiveCoefficients(expression.getLinearExpression()); + resetSolvedValues(); + return this; + } + + public List getVariables() { + return List.copyOf(variables); + } + + public Variable getVariable(int index) { + return variables.get(index); + } + + public Variable getVariable(String variableName) { + for (Variable variable : variables) { + if (variable.getVariableName().equals(variableName)) { + return variable; + } + } + return null; + } + + public List getConstraints() { + return List.copyOf(constraints); + } + + public Constraint getConstraint(int index) { + return constraints.get(index); + } + + public Constraint getConstraint(String constraintName) { + for (Constraint constraint : constraints) { + if (constraint.getConstraintName().equals(constraintName)) { + return constraint; + } + } + return null; + } + + public int getNumVariables() { + return variables.size(); + } + + public int getNumConstraints() { + return constraints.size(); + } + + public boolean isMIP() { + return variables.stream().anyMatch(v -> v.getVariableType() != VariableType.CONTINUOUS); + } + + public TerminationStatus getStatus() { + return status; + } + + public double getObjectiveValue() { + return objectiveValue; + } + + public double getSolveTime() { + return solveTime; + } + + /** The linear constraint matrix in CSR form. Quadratic constraints are not included. */ + public CSRMatrix getConstraintMatrix() { + return buildLinearConstraintMatrix().matrix; + } + + private NativeProblem toNativeProblem() { + MatrixBuild matrixBuild = buildLinearConstraintMatrix(); + double[] objectiveCoefficients = objectiveCoefficients(); + double[] lowerBounds = new double[variables.size()]; + double[] upperBounds = new double[variables.size()]; + byte[] variableTypes = new byte[variables.size()]; + for (Variable variable : variables) { + int index = variable.getIndex(); + lowerBounds[index] = variable.getLowerBound(); + upperBounds[index] = variable.getUpperBound(); + variableTypes[index] = variable.getVariableType().nativeValue(); + } + + NativeProblem nativeProblem = + NativeProblem.createProblem( + matrixBuild.linearConstraints.size(), + variables.size(), + objectiveSense, + objectiveSet ? linearObjective.getConstant() : 0.0, + objectiveCoefficients, + matrixBuild.matrix, + matrixBuild.constraintSense, + matrixBuild.rhs, + lowerBounds, + upperBounds, + variableTypes); + + if (quadraticObjective != null && !quadraticObjective.getQuadraticTerms().isEmpty()) { + nativeProblem.setQuadraticObjective(quadraticObjective); + } + for (Constraint constraint : constraints) { + if (constraint.isQuadratic()) { + nativeProblem.addQuadraticConstraint(constraint); + } + } + String[] variableNames = new String[variables.size()]; + for (Variable variable : variables) { + variableNames[variable.getIndex()] = variable.getVariableName(); + } + String[] rowNames = new String[matrixBuild.linearConstraints.size()]; + for (int i = 0; i < matrixBuild.linearConstraints.size(); i++) { + rowNames[i] = matrixBuild.linearConstraints.get(i).getConstraintName(); + } + nativeProblem.setVariableNames(variableNames).setRowNames(rowNames).setProblemName(name); + return nativeProblem; + } + + public Solution solve() { + return solve(null); + } + + public Solution solve(SolverSettings settings) { + SolverSettings actualSettings = settings == null ? new SolverSettings() : settings; + boolean closeSettings = settings == null; + addMIPStarts(actualSettings); + try (NativeProblem nativeProblem = toNativeProblem()) { + Solution solution = nativeProblem.solve(actualSettings); + try { + populateSolution(solution); + } catch (RuntimeException | Error e) { + solution.close(); + throw e; + } + return solution; + } finally { + if (closeSettings) { + actualSettings.close(); + } + } + } + + /** Writes the problem to {@code path}. The format follows the file extension. */ + public void write(String path) { + try (NativeProblem nativeProblem = toNativeProblem()) { + nativeProblem.write(path); + } + } + + /** Reads a problem from {@code path}. The parser is chosen from the file extension. */ + public static Problem read(String path) { + return read(path, false); + } + + public static Problem read(String path, boolean fixedMPSFormat) { + try (NativeProblem nativeProblem = NativeProblem.read(path, fixedMPSFormat)) { + return fromNativeProblem(nativeProblem); + } + } + + private static Problem fromNativeProblem(NativeProblem nativeProblem) { + Problem problem = new Problem(nativeProblem.getProblemName()); + double[] lowerBounds = nativeProblem.getVariableLowerBounds(); + double[] upperBounds = nativeProblem.getVariableUpperBounds(); + byte[] variableTypes = nativeProblem.getVariableTypes(); + double[] objectiveCoefficients = nativeProblem.getObjectiveCoefficients(); + String[] variableNames = nativeProblem.getVariableNames(); + for (int i = 0; i < nativeProblem.getNumVariables(); i++) { + problem.addVariable( + lowerBounds[i], + upperBounds[i], + objectiveCoefficients[i], + VariableType.fromNative(variableTypes[i]), + variableNames.length > i && !variableNames[i].isEmpty() ? variableNames[i] : "x" + i); + } + + CSRMatrix matrix = nativeProblem.getConstraintMatrix(); + int[] rowOffsets = matrix.getRowOffsets(); + int[] columnIndices = matrix.getColumnIndices(); + double[] values = matrix.getValues(); + byte[] senses = nativeProblem.getConstraintSense(); + double[] rhs = nativeProblem.getConstraintRHS(); + double[] constraintLowerBounds = nativeProblem.getConstraintLowerBounds(); + double[] constraintUpperBounds = nativeProblem.getConstraintUpperBounds(); + String[] rowNames = nativeProblem.getRowNames(); + for (int row = 0; row < nativeProblem.getNumConstraints(); row++) { + LinearExpression expression = new LinearExpression(); + for (int p = rowOffsets[row]; p < rowOffsets[row + 1]; p++) { + expression = expression.plus(problem.getVariable(columnIndices[p]), values[p]); + } + Constraint constraint = + constraintFromNativeBounds( + expression, + senses.length > row ? ConstraintSense.fromNative(senses[row]) : null, + rhs[row], + constraintLowerBounds, + constraintUpperBounds, + row); + problem.addConstraint( + constraint, + rowNames.length > row && !rowNames[row].isEmpty() ? rowNames[row] : "c" + row); + } + + int[] qOffsets = nativeProblem.getQuadraticObjectiveOffsets(); + int[] qIndices = nativeProblem.getQuadraticObjectiveIndices(); + double[] qValues = nativeProblem.getQuadraticObjectiveValues(); + if (qValues.length == 0) { + LinearExpression objective = LinearExpression.ofConstant(nativeProblem.getObjectiveOffset()); + for (int i = 0; i < objectiveCoefficients.length; i++) { + if (objectiveCoefficients[i] != 0.0) { + objective = objective.plus(problem.getVariable(i), objectiveCoefficients[i]); + } + } + problem.setObjective(objective, nativeProblem.getObjectiveSense()); + } else { + QuadraticExpression objective = + new QuadraticExpression().constant(nativeProblem.getObjectiveOffset()); + for (int i = 0; i < objectiveCoefficients.length; i++) { + if (objectiveCoefficients[i] != 0.0) { + objective = objective.plus(problem.getVariable(i), objectiveCoefficients[i]); + } + } + for (int row = 0; row + 1 < qOffsets.length; row++) { + for (int p = qOffsets[row]; p < qOffsets[row + 1]; p++) { + objective = + objective.plus( + problem.getVariable(row), problem.getVariable(qIndices[p]), qValues[p]); + } + } + problem.setObjective(objective, nativeProblem.getObjectiveSense()); + } + + for (QuadraticConstraint quadraticConstraint : nativeProblem.getQuadraticConstraints()) { + QuadraticExpression expression = new QuadraticExpression(); + double[] linearValues = quadraticConstraint.getLinearValues(); + int[] linearIndices = quadraticConstraint.getLinearIndices(); + for (int i = 0; i < linearValues.length; i++) { + expression = expression.plus(problem.getVariable(linearIndices[i]), linearValues[i]); + } + int[] rows = quadraticConstraint.getRows(); + int[] columns = quadraticConstraint.getColumns(); + double[] quadraticValues = quadraticConstraint.getValues(); + for (int i = 0; i < quadraticValues.length; i++) { + expression = + expression.plus( + problem.getVariable(rows[i]), problem.getVariable(columns[i]), quadraticValues[i]); + } + Constraint constraint = + quadraticConstraint.getSense() == ConstraintSense.LE + ? expression.le(quadraticConstraint.getRHS()) + : expression.ge(quadraticConstraint.getRHS()); + problem.addConstraint(constraint, quadraticConstraint.getRowName()); + } + return problem; + } + + + + @Override + public void close() { + // Problem is a Java-side model; native handles are scoped to solve/read/write calls. + } + + void resetSolvedValues() { + variables.forEach(Variable::resetSolvedValues); + constraints.forEach(Constraint::resetSolvedValues); + status = TerminationStatus.NO_TERMINATION; + objectiveValue = Double.NaN; + solveTime = Double.NaN; + } + + /** + * The linear part of the objective. The quadratic part, when there is one, is available as a + * matrix from {@link #getQuadraticObjectiveMatrix()}. + */ + public LinearExpression getObjective() { + return linearObjective; + } + + public ObjectiveSense getObjectiveSense() { + return objectiveSense; + } + + public double getObjectiveConstant() { + return objectiveSet ? linearObjective.getConstant() : 0.0; + } + + public int getNumNonZeros() { + return buildLinearConstraintMatrix().matrix.getValues().length; + } + + public List getQuadraticConstraints() { + List result = new ArrayList<>(); + for (Constraint constraint : constraints) { + if (constraint.isQuadratic()) { + result.add(constraint); + } + } + return List.copyOf(result); + } + + /** The quadratic objective matrix Q in CSR form, or null when the objective is linear. */ + public CSRMatrix getQuadraticObjectiveMatrix() { + if (quadraticObjective == null) { + return null; + } + int n = variables.size(); + int[] offsets = new int[n + 1]; + Map> byRow = new TreeMap<>(); + for (int i = 0; i < n; i++) { + byRow.put(i, new TreeMap<>()); + } + for (QuadraticExpression.QuadraticTerm term : quadraticObjective.getQuadraticTerms()) { + byRow + .get(term.getFirst().getIndex()) + .merge(term.getSecond().getIndex(), term.getCoefficient(), Double::sum); + } + int nnz = 0; + for (int row = 0; row < n; row++) { + offsets[row] = nnz; + nnz += byRow.get(row).size(); + } + offsets[n] = nnz; + int[] columns = new int[nnz]; + double[] coefficients = new double[nnz]; + int position = 0; + for (int row = 0; row < n; row++) { + for (Map.Entry entry : byRow.get(row).entrySet()) { + columns[position] = entry.getKey(); + coefficients[position++] = entry.getValue(); + } + } + return new CSRMatrix(coefficients, columns, offsets); + } + + private void populateSolution(Solution solution) { + resetSolvedValues(); + status = solution.getTerminationStatus(); + solveTime = solution.getSolveTime(); + + double[] primal = solution.getPrimalSolution(); + if (primal.length > 0) { + for (int i = 0; i < variables.size(); i++) { + variables.get(i).setValue(primal[i]); + } + } + if (!solution.isMIP()) { + double[] reducedCosts = solution.getReducedCost(); + if (reducedCosts.length > 0) { + for (int i = 0; i < variables.size(); i++) { + variables.get(i).setReducedCost(reducedCosts[i]); + } + } + double[] dual = solution.getDualSolution(); + int linearRow = 0; + for (Constraint constraint : constraints) { + if (!constraint.isQuadratic()) { + if (dual.length > linearRow) { + constraint.setDualValue(dual[linearRow]); + } + linearRow++; + } + } + } + for (Constraint constraint : constraints) { + constraint.setSlack(constraint.computeSlack()); + } + objectiveValue = solution.getPrimalObjective(); + } + + private void addMIPStarts(SolverSettings settings) { + if (!isMIP()) { + return; + } + double[] starts = new double[variables.size()]; + boolean any = false; + for (Variable variable : variables) { + starts[variable.getIndex()] = variable.getMIPStart(); + any |= !Double.isNaN(variable.getMIPStart()); + } + if (any) { + settings.addMIPStart(starts); + } + } + + private double[] objectiveCoefficients() { + double[] coefficients = new double[variables.size()]; + if (!objectiveSet) { + for (Variable variable : variables) { + coefficients[variable.getIndex()] = variable.getObjectiveCoefficient(); + } + } else { + for (Map.Entry entry : linearObjective.getTerms().entrySet()) { + coefficients[entry.getKey().getIndex()] += entry.getValue(); + } + } + return coefficients; + } + + private void syncVariableObjectiveCoefficients(LinearExpression expression) { + for (Variable variable : variables) { + variable.setObjectiveCoefficient(0.0); + } + for (Map.Entry entry : expression.getTerms().entrySet()) { + if (!variables.contains(entry.getKey())) { + throw new IllegalArgumentException("Objective variable does not belong to this problem"); + } + entry.getKey().setObjectiveCoefficient(entry.getValue()); + } + } + + private LinearExpression objectiveFromVariableCoefficients(double constant) { + LinearExpression expression = LinearExpression.ofConstant(constant); + for (Variable variable : variables) { + if (variable.getObjectiveCoefficient() != 0.0) { + expression = expression.plus(variable, variable.getObjectiveCoefficient()); + } + } + return expression; + } + + private static Constraint constraintFromNativeBounds( + LinearExpression expression, + ConstraintSense sense, + double rhs, + double[] constraintLowerBounds, + double[] constraintUpperBounds, + int row) { + if (constraintLowerBounds.length > row && constraintUpperBounds.length > row) { + double lowerBound = constraintLowerBounds[row]; + double upperBound = constraintUpperBounds[row]; + boolean hasLowerBound = !Double.isInfinite(lowerBound); + boolean hasUpperBound = !Double.isInfinite(upperBound); + if (hasLowerBound && hasUpperBound) { + if (Double.compare(lowerBound, upperBound) == 0) { + return expression.eq(lowerBound); + } + throw new IllegalArgumentException( + "Ranged constraints are not supported by Problem.read: row " + + row + + " has lower bound " + + lowerBound + + " and upper bound " + + upperBound); + } + if (hasLowerBound) { + return expression.ge(lowerBound); + } + if (hasUpperBound) { + return expression.le(upperBound); + } + } + + if (sense == null) { + throw new IllegalStateException( + "Native constraint row " + row + " does not provide bounds or a constraint sense"); + } + switch (sense) { + case LE: + return expression.le(rhs); + case GE: + return expression.ge(rhs); + case EQ: + return expression.eq(rhs); + default: + throw new IllegalStateException("Unsupported sense " + sense); + } + } + + private MatrixBuild buildLinearConstraintMatrix() { + List linearConstraints = new ArrayList<>(); + for (Constraint constraint : constraints) { + if (!constraint.isQuadratic()) { + linearConstraints.add(constraint); + } + } + + int nnz = 0; + for (Constraint constraint : linearConstraints) { + nnz += constraint.getLinearExpression().getTerms().size(); + } + + int[] rowOffsets = new int[linearConstraints.size() + 1]; + int[] columnIndices = new int[nnz]; + double[] values = new double[nnz]; + byte[] senses = new byte[linearConstraints.size()]; + double[] rhs = new double[linearConstraints.size()]; + + int position = 0; + for (int row = 0; row < linearConstraints.size(); row++) { + Constraint constraint = linearConstraints.get(row); + rowOffsets[row] = position; + for (Map.Entry entry : constraint.getLinearExpression().getTerms().entrySet()) { + columnIndices[position] = entry.getKey().getIndex(); + values[position] = entry.getValue(); + position++; + } + senses[row] = constraint.getSense().nativeValue(); + rhs[row] = constraint.getRHS(); + } + rowOffsets[linearConstraints.size()] = position; + return new MatrixBuild( + new CSRMatrix(values, columnIndices, rowOffsets), linearConstraints, senses, rhs); + } + + private static final class MatrixBuild { + private final CSRMatrix matrix; + private final List linearConstraints; + private final byte[] constraintSense; + private final double[] rhs; + + private MatrixBuild( + CSRMatrix matrix, List linearConstraints, byte[] constraintSense, double[] rhs) { + this.matrix = matrix; + this.linearConstraints = linearConstraints; + this.constraintSense = Arrays.copyOf(constraintSense, constraintSense.length); + this.rhs = Arrays.copyOf(rhs, rhs.length); + } + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ProblemCategory.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ProblemCategory.java new file mode 100644 index 0000000000..94b6dba3ee --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/ProblemCategory.java @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +/** Problem categories returned by cuOpt solutions. */ +public enum ProblemCategory { + LP(0), + MIP(1); + + /** + * The engine still reports a third category for a problem whose variables are all discrete (see + * problem_category_t::IP). It is not surfaced here, because the distinction carries no meaning + * for a caller that has already been told the problem is not an LP, and it is folded into + * {@link #MIP} instead. + */ + private static final int NATIVE_ALL_INTEGER = 2; + + private final int nativeValue; + + ProblemCategory(int nativeValue) { + this.nativeValue = nativeValue; + } + + public int nativeValue() { + return nativeValue; + } + + static ProblemCategory fromNative(int value) { + if (value == NATIVE_ALL_INTEGER) { + return MIP; + } + for (ProblemCategory category : values()) { + if (category.nativeValue == value) { + return category; + } + } + throw new IllegalArgumentException("Unknown problem category: " + value); + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/QuadraticConstraint.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/QuadraticConstraint.java new file mode 100644 index 0000000000..dffc0afc2c --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/QuadraticConstraint.java @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.util.Arrays; + +/** Host-side representation of a quadratic constraint in COO form. */ +public final class QuadraticConstraint { + private final int rowIndex; + private final String rowName; + private final ConstraintSense sense; + private final double[] linearValues; + private final int[] linearIndices; + private final double rhs; + private final int[] rows; + private final int[] columns; + private final double[] values; + + public QuadraticConstraint( + int rowIndex, + String rowName, + ConstraintSense sense, + double[] linearValues, + int[] linearIndices, + double rhs, + int[] rows, + int[] columns, + double[] values) { + if (linearValues.length != linearIndices.length) { + throw new IllegalArgumentException("linearValues and linearIndices must have the same length"); + } + if (rows.length != columns.length || rows.length != values.length) { + throw new IllegalArgumentException("quadratic COO arrays must have the same length"); + } + if (sense == ConstraintSense.EQ) { + throw new IllegalArgumentException("Equality quadratic constraints are not supported"); + } + this.rowIndex = rowIndex; + this.rowName = rowName == null ? "" : rowName; + this.sense = sense; + this.linearValues = Arrays.copyOf(linearValues, linearValues.length); + this.linearIndices = Arrays.copyOf(linearIndices, linearIndices.length); + this.rhs = rhs; + this.rows = Arrays.copyOf(rows, rows.length); + this.columns = Arrays.copyOf(columns, columns.length); + this.values = Arrays.copyOf(values, values.length); + } + + public int getRowIndex() { + return rowIndex; + } + + public String getRowName() { + return rowName; + } + + public ConstraintSense getSense() { + return sense; + } + + public double[] getLinearValues() { + return Arrays.copyOf(linearValues, linearValues.length); + } + + public int[] getLinearIndices() { + return Arrays.copyOf(linearIndices, linearIndices.length); + } + + public double getRHS() { + return rhs; + } + + public int[] getRows() { + return Arrays.copyOf(rows, rows.length); + } + + public int[] getColumns() { + return Arrays.copyOf(columns, columns.length); + } + + public double[] getValues() { + return Arrays.copyOf(values, values.length); + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/QuadraticExpression.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/QuadraticExpression.java new file mode 100644 index 0000000000..514663ea8e --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/QuadraticExpression.java @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class QuadraticExpression { + public static final class QuadraticTerm { + private final Variable first; + private final Variable second; + private final double coefficient; + + QuadraticTerm(Variable first, Variable second, double coefficient) { + this.first = first; + this.second = second; + this.coefficient = coefficient; + } + + public Variable getFirst() { + return first; + } + + public Variable getSecond() { + return second; + } + + public double getCoefficient() { + return coefficient; + } + } + + private final LinearExpression linearExpression; + private final List quadraticTerms; + + public QuadraticExpression() { + this(new LinearExpression(), new ArrayList<>()); + } + + private QuadraticExpression(LinearExpression linearExpression, List quadraticTerms) { + this.linearExpression = linearExpression; + this.quadraticTerms = quadraticTerms; + } + + public static QuadraticExpression of(Variable first, Variable second, double coefficient) { + return new QuadraticExpression().plus(first, second, coefficient); + } + + public QuadraticExpression plus(Variable first, Variable second, double coefficient) { + List copy = new ArrayList<>(quadraticTerms); + copy.add(new QuadraticTerm(first, second, coefficient)); + return new QuadraticExpression(linearExpression, copy); + } + + public QuadraticExpression plus(Variable variable, double coefficient) { + return new QuadraticExpression(linearExpression.plus(variable, coefficient), quadraticTerms); + } + + public QuadraticExpression plus(Variable variable) { + return plus(variable, 1.0); + } + + public QuadraticExpression plus(double value) { + return constant(value); + } + + public QuadraticExpression plus(LinearExpression expression) { + return new QuadraticExpression(linearExpression.plus(expression), quadraticTerms); + } + + public QuadraticExpression plus(QuadraticExpression expression) { + List copy = new ArrayList<>(quadraticTerms); + copy.addAll(expression.quadraticTerms); + return new QuadraticExpression(linearExpression.plus(expression.linearExpression), copy); + } + + public QuadraticExpression constant(double constant) { + return new QuadraticExpression(linearExpression.constant(constant), quadraticTerms); + } + + public QuadraticExpression minus(QuadraticExpression expression) { + return plus(expression.times(-1.0)); + } + + public QuadraticExpression minus(LinearExpression expression) { + return plus(expression.times(-1.0)); + } + + public QuadraticExpression minus(Variable variable) { + return plus(variable, -1.0); + } + + public QuadraticExpression minus(double value) { + return plus(-value); + } + + public QuadraticExpression times(double scalar) { + List terms = new ArrayList<>(); + for (QuadraticTerm term : quadraticTerms) { + terms.add(new QuadraticTerm(term.first, term.second, term.coefficient * scalar)); + } + return new QuadraticExpression(linearExpression.times(scalar), terms); + } + + public QuadraticExpression dividedBy(double scalar) { + if (scalar == 0.0) { + throw new IllegalArgumentException("Cannot divide a quadratic expression by zero"); + } + return times(1.0 / scalar); + } + + public Constraint le(double rhs) { + return new Constraint(this, ConstraintSense.LE, rhs); + } + + public Constraint le(Variable variable) { + return minus(variable).le(0.0); + } + + public Constraint le(LinearExpression expression) { + return minus(expression).le(0.0); + } + + public Constraint le(QuadraticExpression expression) { + return minus(expression).le(0.0); + } + + public Constraint ge(double rhs) { + return new Constraint(this, ConstraintSense.GE, rhs); + } + + public Constraint ge(Variable variable) { + return minus(variable).ge(0.0); + } + + public Constraint ge(LinearExpression expression) { + return minus(expression).ge(0.0); + } + + public Constraint ge(QuadraticExpression expression) { + return minus(expression).ge(0.0); + } + + public LinearExpression getLinearExpression() { + return linearExpression; + } + + public double getConstant() { + return linearExpression.getConstant(); + } + + public List getQuadraticTerms() { + return Collections.unmodifiableList(quadraticTerms); + } + + public List getVariables() { + List result = new ArrayList<>(); + for (QuadraticTerm term : quadraticTerms) { + result.add(new Variable[] {term.first, term.second}); + } + return Collections.unmodifiableList(result); + } + + public Variable getVariable1(int index) { + return quadraticTerms.get(index).first; + } + + public Variable getVariable2(int index) { + return quadraticTerms.get(index).second; + } + + public List getCoefficients() { + List result = new ArrayList<>(); + for (QuadraticTerm term : quadraticTerms) { + result.add(term.coefficient); + } + return Collections.unmodifiableList(result); + } + + public double getCoefficient(int index) { + return quadraticTerms.get(index).coefficient; + } + + public double getValue() { + double value = linearExpression.getValue(); + for (QuadraticTerm term : quadraticTerms) { + value += term.coefficient * term.first.getValue() * term.second.getValue(); + } + return value; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Solution.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Solution.java new file mode 100644 index 0000000000..a5d13ad24e --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Solution.java @@ -0,0 +1,154 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.lang.ref.Cleaner; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class Solution implements AutoCloseable { + private static final Cleaner CLEANER = Cleaner.create(); + private final NativeHandle nativeHandle; + private final Cleaner.Cleanable cleanable; + private final int numVariables; + private final int numConstraints; + private final boolean mip; + private final ProblemCategory problemCategory; + private final String[] variableNames; + + Solution(long handle, int numVariables, int numConstraints) { + this(handle, numVariables, numConstraints, ProblemCategory.LP, new String[0]); + } + + Solution( + long handle, + int numVariables, + int numConstraints, + ProblemCategory problemCategory, + String[] variableNames) { + this.nativeHandle = new NativeHandle(handle); + this.cleanable = CLEANER.register(this, nativeHandle); + this.numVariables = numVariables; + this.numConstraints = numConstraints; + this.problemCategory = problemCategory; + // The category comes from the problem this solution was produced for, so it already + // answers LP vs MIP; asking the native layer a second time would only add a round trip. + this.mip = problemCategory == ProblemCategory.MIP; + this.variableNames = variableNames == null ? new String[0] : Arrays.copyOf(variableNames, variableNames.length); + } + + long handle() { + nativeHandle.requireOpen(); + return nativeHandle.handle; + } + + public boolean isMIP() { + return mip; + } + + double[] getPrimalSolution() { + return NativeCuOpt.getPrimalSolution(handle(), numVariables); + } + + double[] getDualSolution() { + requireLP("getDualSolution"); + return NativeCuOpt.getDualSolution(handle(), NativeCuOpt.getDualSolutionSize(handle())); + } + + double[] getReducedCost() { + requireLP("getReducedCost"); + return NativeCuOpt.getReducedCosts(handle(), numVariables); + } + + /** + * Package-private: Problem copies this onto itself after a solve, and Problem.getSolveTime is + * the public way to read it. + */ + double getSolveTime() { + return NativeCuOpt.getSolveTime(handle()); + } + + public double getPrimalObjective() { + return NativeCuOpt.getObjectiveValue(handle()); + } + + public double getDualObjective() { + requireLP("getDualObjective"); + return NativeCuOpt.getDualObjectiveValue(handle()); + } + + public TerminationStatus getTerminationStatus() { + return TerminationStatus.fromNative(NativeCuOpt.getTerminationStatus(handle())); + } + + public int getErrorStatus() { + return NativeCuOpt.getErrorStatus(handle()); + } + + public String getErrorMessage() { + return NativeCuOpt.getErrorString(handle()); + } + + public double getMIPGap() { + requireMIP("getMIPGap"); + return NativeCuOpt.getMIPGap(handle()); + } + + public double getSolutionBound() { + requireMIP("getSolutionBound"); + return NativeCuOpt.getSolutionBound(handle()); + } + + public LPStats getLPStats() { + requireLP("getLPStats"); + return new LPStats(NativeCuOpt.getLPStats(handle())); + } + + public MIPStats getMIPStats() { + requireMIP("getMIPStats"); + return new MIPStats(NativeCuOpt.getMIPStats(handle())); + } + + @Override + public void close() { + cleanable.clean(); + } + + private void requireLP(String method) { + if (mip) { + throw new IllegalStateException(method + " is not available for MIP solutions"); + } + } + + private void requireMIP(String method) { + if (!mip) { + throw new IllegalStateException(method + " is not available for LP solutions"); + } + } + + private static final class NativeHandle implements Runnable { + private long handle; + + NativeHandle(long handle) { + this.handle = handle; + } + + void requireOpen() { + if (handle == 0) { + throw new IllegalStateException("Solution is closed"); + } + } + + @Override + public void run() { + if (handle != 0) { + NativeCuOpt.destroySolution(handle); + handle = 0; + } + } + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/SolverMethod.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/SolverMethod.java new file mode 100644 index 0000000000..5efb053cda --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/SolverMethod.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +/** Solver methods backed by constants generated from the C++ public header. */ +public enum SolverMethod { + CONCURRENT(CuOptConstants.CUOPT_METHOD_CONCURRENT), + PDLP(CuOptConstants.CUOPT_METHOD_PDLP), + DUAL_SIMPLEX(CuOptConstants.CUOPT_METHOD_DUAL_SIMPLEX), + BARRIER(CuOptConstants.CUOPT_METHOD_BARRIER), + UNSET(CuOptConstants.CUOPT_METHOD_UNSET); + + private final int nativeValue; + + SolverMethod(int nativeValue) { + this.nativeValue = nativeValue; + } + + public int nativeValue() { + return nativeValue; + } + + static SolverMethod fromNative(int value) { + for (SolverMethod method : values()) { + if (method.nativeValue == value) { + return method; + } + } + return UNSET; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/SolverSettings.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/SolverSettings.java new file mode 100644 index 0000000000..e67774f440 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/SolverSettings.java @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.lang.ref.Cleaner; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public final class SolverSettings implements AutoCloseable { + private static final Cleaner CLEANER = Cleaner.create(); + private final NativeHandle nativeHandle; + private final Cleaner.Cleanable cleanable; + private final List mipCallbacks = new ArrayList<>(); + + public SolverSettings() { + this.nativeHandle = new NativeHandle(NativeCuOpt.createSolverSettings()); + this.cleanable = CLEANER.register(this, nativeHandle); + } + + long handle() { + nativeHandle.requireOpen(); + return nativeHandle.handle; + } + + public SolverSettings setSetting(String name, String value) { + NativeCuOpt.setSetting(handle(), name, value); + return this; + } + + public SolverSettings setSetting(String name, int value) { + NativeCuOpt.setIntegerSetting(handle(), name, value); + return this; + } + + public SolverSettings setSetting(String name, double value) { + NativeCuOpt.setFloatSetting(handle(), name, value); + return this; + } + + public SolverSettings setSetting(String name, boolean value) { + NativeCuOpt.setIntegerSetting(handle(), name, value ? 1 : 0); + return this; + } + + public String getSetting(String name) { + return getSettingAsString(name); + } + + public T getSetting(String name, Class type) { + return parseValue(getSettingAsString(name), type); + } + + public String getSettingAsString(String name) { + return NativeCuOpt.getSetting(handle(), name); + } + + public SolverSettings setMethod(SolverMethod method) { + return setSetting(CuOptConstants.CUOPT_METHOD, method.nativeValue()); + } + + public SolverSettings setPDLPSolverMode(PDLPSolverMode mode) { + return setSetting(CuOptConstants.CUOPT_PDLP_SOLVER_MODE, mode.nativeValue()); + } + + /** The LP optimality tolerances, previously discovered by filtering on parameter names. */ + private static final String[] OPTIMALITY_TOLERANCES = { + CuOptConstants.CUOPT_ABSOLUTE_PRIMAL_TOLERANCE, + CuOptConstants.CUOPT_RELATIVE_PRIMAL_TOLERANCE, + CuOptConstants.CUOPT_ABSOLUTE_DUAL_TOLERANCE, + CuOptConstants.CUOPT_RELATIVE_DUAL_TOLERANCE, + CuOptConstants.CUOPT_ABSOLUTE_GAP_TOLERANCE, + CuOptConstants.CUOPT_RELATIVE_GAP_TOLERANCE, + }; + + public SolverSettings setOptimalityTolerance(double tolerance) { + for (String setting : OPTIMALITY_TOLERANCES) { + setSetting(setting, tolerance); + } + return this; + } + + + public SolverSettings addMIPStart(double[] values) { + NativeCuOpt.addMIPStart(handle(), Arrays.copyOf(values, values.length)); + return this; + } + + /** + * Warm-start PDLP with a primal solution of length {@code numVariables}. + * + *

The Python API exposes this on {@code DataModel}; in Java it lives here, matching the C API, + * where it is a solver setting rather than part of the problem. + */ + public SolverSettings setInitialPrimalSolution(double[] values) { + NativeCuOpt.setInitialPrimalSolution(handle(), Arrays.copyOf(values, values.length)); + return this; + } + + /** + * Warm-start PDLP with a dual solution of length {@code numConstraints}. + * + *

The Python API exposes this on {@code DataModel}; in Java it lives here, matching the C API, + * where it is a solver setting rather than part of the problem. + */ + public SolverSettings setInitialDualSolution(double[] values) { + NativeCuOpt.setInitialDualSolution(handle(), Arrays.copyOf(values, values.length)); + return this; + } + + public SolverSettings setMIPCallback( + MIPSolutionCallback callback, Object userData, int numVariables) { + NativeCuOpt.registerMIPGetSolutionCallback(handle(), callback, userData, numVariables); + mipCallbacks.add(callback); + return this; + } + + public SolverSettings setMIPCallback( + MIPSetSolutionCallback callback, Object userData, int numVariables) { + NativeCuOpt.registerMIPSetSolutionCallback(handle(), callback, userData, numVariables); + mipCallbacks.add(callback); + return this; + } + + public List getMIPCallbacks() { + return Collections.unmodifiableList(mipCallbacks); + } + + + + private static T parseValue(String value, Class type) { + if (type == String.class) { + return type.cast(value); + } + if (type == Boolean.class) { + if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { + throw new IllegalArgumentException("Setting value is not a boolean: " + value); + } + return type.cast(Boolean.valueOf(value)); + } + try { + if (type == Integer.class) { + return type.cast(Integer.valueOf(value)); + } + if (type == Double.class) { + return type.cast(Double.valueOf(value)); + } + } catch (NumberFormatException exception) { + throw new IllegalArgumentException( + "Setting value cannot be converted to " + type.getSimpleName() + ": " + value, + exception); + } + throw new IllegalArgumentException( + "Unsupported setting type: " + type.getName() + + "; use String, Boolean, Integer, or Double"); + } + + + + @Override + public void close() { + cleanable.clean(); + } + + private static final class NativeHandle implements Runnable { + private long handle; + + NativeHandle(long handle) { + this.handle = handle; + } + + void requireOpen() { + if (handle == 0) { + throw new IllegalStateException("SolverSettings is closed"); + } + } + + @Override + public void run() { + if (handle != 0) { + NativeCuOpt.destroySolverSettings(handle); + handle = 0; + } + } + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/TerminationStatus.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/TerminationStatus.java new file mode 100644 index 0000000000..126418f22f --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/TerminationStatus.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +/** Termination statuses backed by constants generated from the C++ public header. */ +public enum TerminationStatus { + NO_TERMINATION(CuOptConstants.CUOPT_TERMINATION_STATUS_NO_TERMINATION), + OPTIMAL(CuOptConstants.CUOPT_TERMINATION_STATUS_OPTIMAL), + INFEASIBLE(CuOptConstants.CUOPT_TERMINATION_STATUS_INFEASIBLE), + UNBOUNDED(CuOptConstants.CUOPT_TERMINATION_STATUS_UNBOUNDED), + ITERATION_LIMIT(CuOptConstants.CUOPT_TERMINATION_STATUS_ITERATION_LIMIT), + TIME_LIMIT(CuOptConstants.CUOPT_TERMINATION_STATUS_TIME_LIMIT), + NUMERICAL_ERROR(CuOptConstants.CUOPT_TERMINATION_STATUS_NUMERICAL_ERROR), + PRIMAL_FEASIBLE(CuOptConstants.CUOPT_TERMINATION_STATUS_PRIMAL_FEASIBLE), + FEASIBLE_FOUND(CuOptConstants.CUOPT_TERMINATION_STATUS_FEASIBLE_FOUND), + CONCURRENT_LIMIT(CuOptConstants.CUOPT_TERMINATION_STATUS_CONCURRENT_LIMIT), + WORK_LIMIT(CuOptConstants.CUOPT_TERMINATION_STATUS_WORK_LIMIT), + UNBOUNDED_OR_INFEASIBLE( + CuOptConstants.CUOPT_TERMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE), + UNKNOWN(Integer.MIN_VALUE); + + private final int nativeValue; + + TerminationStatus(int nativeValue) { + this.nativeValue = nativeValue; + } + + public int nativeValue() { + return nativeValue; + } + + static TerminationStatus fromNative(int value) { + for (TerminationStatus status : values()) { + if (status.nativeValue == value) { + return status; + } + } + return UNKNOWN; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Variable.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Variable.java new file mode 100644 index 0000000000..b3c33f4fd6 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/Variable.java @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +public final class Variable { + private final int index; + private double lowerBound; + private double upperBound; + private double objectiveCoefficient; + private VariableType variableType; + private String name; + private double value = Double.NaN; + private double reducedCost = Double.NaN; + private double mipStart = Double.NaN; + + Variable( + int index, + double lowerBound, + double upperBound, + double objectiveCoefficient, + VariableType variableType, + String name) { + this.index = index; + this.lowerBound = lowerBound; + this.upperBound = upperBound; + this.objectiveCoefficient = objectiveCoefficient; + this.variableType = variableType; + this.name = name == null ? "" : name; + } + + public int getIndex() { + return index; + } + + public double getLowerBound() { + return lowerBound; + } + + public Variable setLowerBound(double lowerBound) { + this.lowerBound = lowerBound; + return this; + } + + public double getUpperBound() { + return upperBound; + } + + public Variable setUpperBound(double upperBound) { + this.upperBound = upperBound; + return this; + } + + public double getObjectiveCoefficient() { + return objectiveCoefficient; + } + + public Variable setObjectiveCoefficient(double objectiveCoefficient) { + this.objectiveCoefficient = objectiveCoefficient; + return this; + } + + public VariableType getVariableType() { + return variableType; + } + + public Variable setVariableType(VariableType variableType) { + this.variableType = variableType; + return this; + } + + public String getVariableName() { + return name; + } + + public Variable setVariableName(String name) { + this.name = name == null ? "" : name; + return this; + } + + public double getValue() { + return value; + } + + void setValue(double value) { + this.value = value; + } + + public double getReducedCost() { + return reducedCost; + } + + void setReducedCost(double reducedCost) { + this.reducedCost = reducedCost; + } + + public double getMIPStart() { + return mipStart; + } + + public Variable setMIPStart(double mipStart) { + this.mipStart = mipStart; + return this; + } + + void resetSolvedValues() { + value = Double.NaN; + reducedCost = Double.NaN; + } +} diff --git a/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/VariableType.java b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/VariableType.java new file mode 100644 index 0000000000..96da4c43c2 --- /dev/null +++ b/java/cuopt/src/main/java/com/nvidia/cuopt/mathematicalprogramming/VariableType.java @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +public enum VariableType { + CONTINUOUS((byte) 'C'), + INTEGER((byte) 'I'), + SEMI_CONTINUOUS((byte) 'S'); + + private final byte nativeValue; + + VariableType(byte nativeValue) { + this.nativeValue = nativeValue; + } + + byte nativeValue() { + return nativeValue; + } + + static VariableType fromNative(byte value) { + for (VariableType type : values()) { + if (type.nativeValue == value) { + return type; + } + } + throw new IllegalArgumentException("Unknown variable type: " + (char) value); + } +} diff --git a/java/cuopt/src/main/native/cuopt_jni.cpp b/java/cuopt/src/main/native/cuopt_jni.cpp new file mode 100644 index 0000000000..7b70c4b520 --- /dev/null +++ b/java/cuopt/src/main/native/cuopt_jni.cpp @@ -0,0 +1,1159 @@ +/* + * 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 +#include +#include + +#include + +namespace { + +JavaVM* g_jvm = nullptr; + +struct java_callback_context_t { + jobject callback{nullptr}; + jobject user_data{nullptr}; + int num_variables{0}; +}; + +std::mutex g_callback_mutex; +std::unordered_map> g_callback_contexts; + +// Problems created directly by this JNI module must be destroyed here as well. +// Passing these C++ objects to cuOptDestroyProblem in libcuopt.so crosses the +// shared-library boundary with a private wrapper type. +std::mutex g_jni_owned_problem_mutex; +std::unordered_set g_jni_owned_problem_handles; + +cuOptOptimizationProblem to_problem(jlong handle) +{ + return reinterpret_cast(handle); +} + +cuopt::mathematical_optimization::problem_and_stream_view_t* to_problem_view(jlong handle) +{ + return reinterpret_cast(handle); +} + +cuOptSolverSettings to_settings(jlong handle) +{ + return reinterpret_cast(handle); +} + +cuOptSolution to_solution(jlong handle) { return reinterpret_cast(handle); } + +jlong from_handle(void* handle) { return reinterpret_cast(handle); } + +void remember_jni_owned_problem(void* handle) +{ + std::lock_guard lock(g_jni_owned_problem_mutex); + g_jni_owned_problem_handles.insert(from_handle(handle)); +} + +bool take_jni_owned_problem(jlong handle) +{ + std::lock_guard lock(g_jni_owned_problem_mutex); + return g_jni_owned_problem_handles.erase(handle) != 0; +} + +std::vector get_double_array(JNIEnv* env, jdoubleArray array) +{ + if (array == nullptr) { return {}; } + const jsize len = env->GetArrayLength(array); + std::vector tmp(static_cast(len)); + env->GetDoubleArrayRegion(array, 0, len, tmp.data()); + return std::vector(tmp.begin(), tmp.end()); +} + +std::vector get_int_array(JNIEnv* env, jintArray array) +{ + if (array == nullptr) { return {}; } + const jsize len = env->GetArrayLength(array); + std::vector tmp(static_cast(len)); + env->GetIntArrayRegion(array, 0, len, tmp.data()); + return std::vector(tmp.begin(), tmp.end()); +} + +std::vector get_byte_array(JNIEnv* env, jbyteArray array) +{ + if (array == nullptr) { return {}; } + const jsize len = env->GetArrayLength(array); + std::vector tmp(static_cast(len)); + env->GetByteArrayRegion(array, 0, len, tmp.data()); + return std::vector(tmp.begin(), tmp.end()); +} + +std::string get_string(JNIEnv* env, jstring value); + +std::vector get_string_array(JNIEnv* env, jobjectArray array) +{ + if (array == nullptr) { return {}; } + const jsize len = env->GetArrayLength(array); + std::vector values; + values.reserve(static_cast(len)); + for (jsize i = 0; i < len; ++i) { + values.push_back(get_string(env, static_cast(env->GetObjectArrayElement(array, i)))); + } + return values; +} + +jobjectArray to_string_array(JNIEnv* env, const std::vector& values) +{ + jclass string_class = env->FindClass("java/lang/String"); + jobjectArray result = + env->NewObjectArray(static_cast(values.size()), string_class, nullptr); + for (jsize i = 0; i < static_cast(values.size()); ++i) { + env->SetObjectArrayElement( + result, i, env->NewStringUTF(values[static_cast(i)].c_str())); + } + return result; +} + +jdoubleArray to_double_array(JNIEnv* env, const std::vector& values) +{ + jdoubleArray result = env->NewDoubleArray(static_cast(values.size())); + std::vector tmp(values.begin(), values.end()); + env->SetDoubleArrayRegion(result, 0, static_cast(tmp.size()), tmp.data()); + return result; +} + +jintArray to_int_array(JNIEnv* env, const std::vector& values) +{ + jintArray result = env->NewIntArray(static_cast(values.size())); + std::vector tmp(values.begin(), values.end()); + env->SetIntArrayRegion(result, 0, static_cast(tmp.size()), tmp.data()); + return result; +} + +jbyteArray to_byte_array(JNIEnv* env, const std::vector& values) +{ + jbyteArray result = env->NewByteArray(static_cast(values.size())); + std::vector tmp(values.begin(), values.end()); + env->SetByteArrayRegion(result, 0, static_cast(tmp.size()), tmp.data()); + return result; +} + +std::string get_string(JNIEnv* env, jstring value) +{ + if (value == nullptr) { return {}; } + const char* chars = env->GetStringUTFChars(value, nullptr); + std::string result(chars); + env->ReleaseStringUTFChars(value, chars); + return result; +} + +void throw_cuopt_exception(JNIEnv* env, cuopt_int_t status, const std::string& message) +{ + jclass cls = env->FindClass("com/nvidia/cuopt/mathematicalprogramming/CuOptException"); + if (cls == nullptr) { return; } + jmethodID ctor = env->GetMethodID(cls, "", "(ILjava/lang/String;)V"); + if (ctor == nullptr) { return; } + jstring msg = env->NewStringUTF(message.c_str()); + jobject ex = env->NewObject(cls, ctor, static_cast(status), msg); + env->Throw(static_cast(ex)); +} + +void throw_illegal_state(JNIEnv* env, const std::string& message) +{ + jclass cls = env->FindClass("java/lang/IllegalStateException"); + if (cls == nullptr) { return; } + env->ThrowNew(cls, message.c_str()); +} + +bool check_status(JNIEnv* env, cuopt_int_t status, const char* operation) +{ + if (status == CUOPT_SUCCESS) { return true; } + throw_cuopt_exception( + env, status, std::string(operation) + " failed with status " + std::to_string(status)); + return false; +} + +cuopt::mathematical_optimization::lp_solution_interface_t* +to_lp_solution(JNIEnv* env, jlong handle, const char* operation) +{ + auto* solution = + reinterpret_cast(handle); + if (solution == nullptr || solution->is_mip || solution->lp_solution_interface_ptr == nullptr) { + throw_illegal_state(env, std::string(operation) + " is only available for LP solutions"); + return nullptr; + } + return solution->lp_solution_interface_ptr; +} + +template +bool run_problem_operation(JNIEnv* env, const char* operation, F&& operation_fn) +{ + try { + operation_fn(); + return true; + } catch (const std::exception& e) { + throw_cuopt_exception( + env, CUOPT_INVALID_ARGUMENT, std::string(operation) + " failed: " + e.what()); + return false; + } +} + +JNIEnv* get_callback_env(bool& detach) +{ + detach = false; + JNIEnv* env = nullptr; + if (g_jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_8) == JNI_OK) { return env; } + if (g_jvm->AttachCurrentThread(reinterpret_cast(&env), nullptr) == JNI_OK) { + detach = true; + return env; + } + return nullptr; +} + +void cleanup_callback_contexts(JNIEnv* env, jlong settings_handle) +{ + std::vector contexts; + { + std::lock_guard lock(g_callback_mutex); + auto it = g_callback_contexts.find(settings_handle); + if (it == g_callback_contexts.end()) { return; } + contexts = std::move(it->second); + g_callback_contexts.erase(it); + } + for (auto* context : contexts) { + if (context->callback != nullptr) { env->DeleteGlobalRef(context->callback); } + if (context->user_data != nullptr) { env->DeleteGlobalRef(context->user_data); } + delete context; + } +} + +void remember_callback_context(jlong settings_handle, java_callback_context_t* context) +{ + std::lock_guard lock(g_callback_mutex); + g_callback_contexts[settings_handle].push_back(context); +} + +void mip_get_solution_callback(const cuopt_float_t* solution, + const cuopt_float_t* objective_value, + const cuopt_float_t* solution_bound, + void* user_data) +{ + auto* context = static_cast(user_data); + if (context == nullptr || context->callback == nullptr) { return; } + + bool detach = false; + JNIEnv* env = get_callback_env(detach); + if (env == nullptr) { return; } + + jclass cls = env->GetObjectClass(context->callback); + if (cls != nullptr) { + jmethodID method = env->GetMethodID(cls, "onSolution", "([DDDLjava/lang/Object;)V"); + if (method != nullptr) { + std::vector values(solution, solution + context->num_variables); + jdoubleArray solution_array = to_double_array(env, values); + env->CallVoidMethod(context->callback, + method, + solution_array, + static_cast(*objective_value), + static_cast(*solution_bound), + context->user_data); + env->DeleteLocalRef(solution_array); + } + env->DeleteLocalRef(cls); + } + + if (detach) { g_jvm->DetachCurrentThread(); } +} + +void mip_set_solution_callback(cuopt_float_t* solution, + cuopt_float_t* objective_value, + const cuopt_float_t* solution_bound, + void* user_data) +{ + auto* context = static_cast(user_data); + if (context == nullptr || context->callback == nullptr) { return; } + + bool detach = false; + JNIEnv* env = get_callback_env(detach); + if (env == nullptr) { return; } + + jclass cls = env->GetObjectClass(context->callback); + if (cls != nullptr) { + jmethodID method = env->GetMethodID( + cls, + "getSolution", + "(DLjava/lang/Object;)Lcom/nvidia/cuopt/mathematicalprogramming/MIPCallbackSolution;"); + if (method != nullptr) { + jobject callback_solution = env->CallObjectMethod( + context->callback, method, static_cast(*solution_bound), context->user_data); + if (callback_solution != nullptr) { + jclass result_cls = env->GetObjectClass(callback_solution); + if (result_cls != nullptr) { + jfieldID solution_field = env->GetFieldID(result_cls, "solution", "[D"); + jfieldID objective_field = env->GetFieldID(result_cls, "objectiveValue", "D"); + if (solution_field != nullptr && objective_field != nullptr) { + auto solution_array = + static_cast(env->GetObjectField(callback_solution, solution_field)); + const auto values = get_double_array(env, solution_array); + if (values.size() == static_cast(context->num_variables)) { + std::memcpy(solution, values.data(), values.size() * sizeof(cuopt_float_t)); + *objective_value = + static_cast(env->GetDoubleField(callback_solution, objective_field)); + } else { + throw_illegal_state(env, + "MIP set-solution callback returned " + + std::to_string(values.size()) + " values for " + + std::to_string(context->num_variables) + " variables"); + } + if (solution_array != nullptr) { env->DeleteLocalRef(solution_array); } + } + env->DeleteLocalRef(result_cls); + } + env->DeleteLocalRef(callback_solution); + } + } + env->DeleteLocalRef(cls); + } + + if (detach) { g_jvm->DetachCurrentThread(); } +} + +} // namespace + +extern "C" jint JNI_OnLoad(JavaVM* vm, void*) +{ + g_jvm = vm; + return JNI_VERSION_1_8; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getFloatSize(JNIEnv*, jclass) +{ + return cuOptGetFloatSize(); +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_readProblemWithFormat( + JNIEnv* env, jclass, jstring path, jboolean fixed_mps_format) +{ + const auto filename = get_string(env, path); + auto problem = std::make_unique( + cuopt::mathematical_optimization::get_memory_backend_type()); + try { + auto data_model = cuopt::mathematical_optimization::io::read( + filename, static_cast(fixed_mps_format)); + cuopt::mathematical_optimization::populate_from_mps_data_model(problem->get_problem(), + data_model); + auto* raw_problem = problem.get(); + remember_jni_owned_problem(raw_problem); + problem.release(); + return from_handle(raw_problem); + } catch (const std::exception& e) { + const cuopt_int_t status = + std::string(e.what()).find("Error opening input file") != std::string::npos + ? CUOPT_MPS_FILE_ERROR + : CUOPT_MPS_PARSE_ERROR; + throw_cuopt_exception(env, status, std::string("readProblemWithFormat failed: ") + e.what()); + return 0; + } +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_createSolverSettings(JNIEnv* env, jclass) +{ + cuOptSolverSettings settings = nullptr; + if (!check_status(env, cuOptCreateSolverSettings(&settings), "cuOptCreateSolverSettings")) { + return 0; + } + return from_handle(settings); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_destroySolverSettings(JNIEnv* env, + jclass, + jlong handle) +{ + if (handle == 0) { return; } + cleanup_callback_contexts(env, handle); + cuOptSolverSettings settings = to_settings(handle); + cuOptDestroySolverSettings(&settings); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setSetting( + JNIEnv* env, jclass, jlong handle, jstring name, jstring value) +{ + const auto parameter_name = get_string(env, name); + const auto parameter_value = get_string(env, value); + check_status( + env, + cuOptSetParameter(to_settings(handle), parameter_name.c_str(), parameter_value.c_str()), + "cuOptSetParameter"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setIntegerSetting( + JNIEnv* env, jclass, jlong handle, jstring name, jint value) +{ + const auto parameter_name = get_string(env, name); + check_status(env, + cuOptSetIntegerParameter(to_settings(handle), parameter_name.c_str(), value), + "cuOptSetIntegerParameter"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setFloatSetting( + JNIEnv* env, jclass, jlong handle, jstring name, jdouble value) +{ + const auto parameter_name = get_string(env, name); + check_status(env, + cuOptSetFloatParameter( + to_settings(handle), parameter_name.c_str(), static_cast(value)), + "cuOptSetFloatParameter"); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getSetting(JNIEnv* env, + jclass, + jlong handle, + jstring name) +{ + const auto parameter_name = get_string(env, name); + char buffer[256] = {}; + if (!check_status( + env, + cuOptGetParameter(to_settings(handle), parameter_name.c_str(), sizeof(buffer), buffer), + "cuOptGetParameter")) { + return nullptr; + } + return env->NewStringUTF(buffer); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_addMIPStart(JNIEnv* env, + jclass, + jlong handle, + jdoubleArray values) +{ + const auto data = get_double_array(env, values); + check_status( + env, + cuOptAddMIPStart(to_settings(handle), data.data(), static_cast(data.size())), + "cuOptAddMIPStart"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setInitialPrimalSolution( + JNIEnv* env, jclass, jlong handle, jdoubleArray values) +{ + const auto data = get_double_array(env, values); + check_status(env, + cuOptSetInitialPrimalSolution( + to_settings(handle), data.data(), static_cast(data.size())), + "cuOptSetInitialPrimalSolution"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setInitialDualSolution( + JNIEnv* env, jclass, jlong handle, jdoubleArray values) +{ + const auto data = get_double_array(env, values); + check_status(env, + cuOptSetInitialDualSolution( + to_settings(handle), data.data(), static_cast(data.size())), + "cuOptSetInitialDualSolution"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_registerMIPGetSolutionCallback( + JNIEnv* env, jclass, jlong handle, jobject callback, jobject user_data, jint num_variables) +{ + auto* context = new java_callback_context_t; + context->callback = env->NewGlobalRef(callback); + context->user_data = user_data == nullptr ? nullptr : env->NewGlobalRef(user_data); + context->num_variables = num_variables; + const auto status = + cuOptSetMIPGetSolutionCallback(to_settings(handle), mip_get_solution_callback, context); + if (!check_status(env, status, "cuOptSetMIPGetSolutionCallback")) { + if (context->callback != nullptr) { env->DeleteGlobalRef(context->callback); } + if (context->user_data != nullptr) { env->DeleteGlobalRef(context->user_data); } + delete context; + return; + } + remember_callback_context(handle, context); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_registerMIPSetSolutionCallback( + JNIEnv* env, jclass, jlong handle, jobject callback, jobject user_data, jint num_variables) +{ + auto* context = new java_callback_context_t; + context->callback = env->NewGlobalRef(callback); + context->user_data = user_data == nullptr ? nullptr : env->NewGlobalRef(user_data); + context->num_variables = num_variables; + const auto status = + cuOptSetMIPSetSolutionCallback(to_settings(handle), mip_set_solution_callback, context); + if (!check_status(env, status, "cuOptSetMIPSetSolutionCallback")) { + if (context->callback != nullptr) { env->DeleteGlobalRef(context->callback); } + if (context->user_data != nullptr) { env->DeleteGlobalRef(context->user_data); } + delete context; + return; + } + remember_callback_context(handle, context); +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_createProblem( + JNIEnv* env, + jclass, + jint num_constraints, + jint num_variables, + jint objective_sense, + jdouble objective_offset, + jdoubleArray objective_coefficients, + jintArray row_offsets, + jintArray column_indices, + jdoubleArray values, + jbyteArray constraint_sense, + jdoubleArray rhs, + jdoubleArray lower_bounds, + jdoubleArray upper_bounds, + jbyteArray variable_types) +{ + const auto obj = get_double_array(env, objective_coefficients); + const auto rows = get_int_array(env, row_offsets); + const auto cols = get_int_array(env, column_indices); + const auto coeffs = get_double_array(env, values); + const auto senses = get_byte_array(env, constraint_sense); + const auto rhs_values = get_double_array(env, rhs); + const auto lbs = get_double_array(env, lower_bounds); + const auto ubs = get_double_array(env, upper_bounds); + const auto types = get_byte_array(env, variable_types); + cuOptOptimizationProblem problem = nullptr; + if (!check_status(env, + cuOptCreateProblem(num_constraints, + num_variables, + objective_sense, + static_cast(objective_offset), + obj.data(), + rows.data(), + cols.data(), + coeffs.data(), + senses.data(), + rhs_values.data(), + lbs.data(), + ubs.data(), + types.data(), + &problem), + "cuOptCreateProblem")) { + return 0; + } + return from_handle(problem); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_writeProblem(JNIEnv* env, + jclass, + jlong handle, + jstring path) +{ + const auto filename = get_string(env, path); + check_status(env, + cuOptWriteProblem(to_problem(handle), filename.c_str(), CUOPT_FILE_FORMAT_MPS), + "cuOptWriteProblem"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_destroyProblem(JNIEnv*, + jclass, + jlong handle) +{ + if (handle == 0) { return; } + if (take_jni_owned_problem(handle)) { + delete to_problem_view(handle); + return; + } + cuOptOptimizationProblem problem = to_problem(handle); + cuOptDestroyProblem(&problem); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setQuadraticObjective( + JNIEnv* env, jclass, jlong handle, jintArray rows, jintArray cols, jdoubleArray coeffs) +{ + const auto row_data = get_int_array(env, rows); + const auto col_data = get_int_array(env, cols); + const auto val_data = get_double_array(env, coeffs); + check_status(env, + cuOptSetQuadraticObjective(to_problem(handle), + static_cast(val_data.size()), + row_data.data(), + col_data.data(), + val_data.data()), + "cuOptSetQuadraticObjective"); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_addQuadraticConstraint( + JNIEnv* env, + jclass, + jlong handle, + jintArray rows, + jintArray cols, + jdoubleArray coeffs, + jintArray linear_indices, + jdoubleArray linear_coeffs, + jbyte sense, + jdouble rhs) +{ + const auto row_data = get_int_array(env, rows); + const auto col_data = get_int_array(env, cols); + const auto val_data = get_double_array(env, coeffs); + const auto lin_idx = get_int_array(env, linear_indices); + const auto lin_coeff = get_double_array(env, linear_coeffs); + check_status(env, + cuOptAddQuadraticConstraint(to_problem(handle), + static_cast(val_data.size()), + row_data.data(), + col_data.data(), + val_data.data(), + static_cast(lin_coeff.size()), + lin_idx.data(), + lin_coeff.data(), + static_cast(sense), + static_cast(rhs)), + "cuOptAddQuadraticConstraint"); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumVariables(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_int_t value = 0; + check_status(env, cuOptGetNumVariables(to_problem(handle), &value), "cuOptGetNumVariables"); + return value; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumConstraints(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_int_t value = 0; + check_status(env, cuOptGetNumConstraints(to_problem(handle), &value), "cuOptGetNumConstraints"); + return value; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumNonZeros(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_int_t value = 0; + check_status(env, cuOptGetNumNonZeros(to_problem(handle), &value), "cuOptGetNumNonZeros"); + return value; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getObjectiveSense(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_int_t value = 0; + check_status(env, cuOptGetObjectiveSense(to_problem(handle), &value), "cuOptGetObjectiveSense"); + return value; +} + +extern "C" JNIEXPORT jdouble JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getObjectiveOffset(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_float_t value = 0; + check_status(env, cuOptGetObjectiveOffset(to_problem(handle), &value), "cuOptGetObjectiveOffset"); + return value; +} + +extern "C" JNIEXPORT jdoubleArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getObjectiveCoefficients(JNIEnv* env, + jclass, + jlong handle) +{ + const int n = + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumVariables(env, nullptr, handle); + std::vector values(static_cast(n)); + if (!check_status(env, + cuOptGetObjectiveCoefficients(to_problem(handle), values.data()), + "cuOptGetObjectiveCoefficients")) { + return nullptr; + } + return to_double_array(env, values); +} + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getConstraintMatrix(JNIEnv* env, + jclass, + jlong handle) +{ + const int rows_size = Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumConstraints( + env, nullptr, handle) + + 1; + const int nnz = + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumNonZeros(env, nullptr, handle); + std::vector rows(static_cast(rows_size)); + std::vector cols(static_cast(nnz)); + std::vector values(static_cast(nnz)); + if (!check_status( + env, + cuOptGetConstraintMatrix(to_problem(handle), rows.data(), cols.data(), values.data()), + "cuOptGetConstraintMatrix")) { + return nullptr; + } + jclass object_class = env->FindClass("java/lang/Object"); + jobjectArray result = env->NewObjectArray(3, object_class, nullptr); + env->SetObjectArrayElement(result, 0, to_int_array(env, rows)); + env->SetObjectArrayElement(result, 1, to_int_array(env, cols)); + env->SetObjectArrayElement(result, 2, to_double_array(env, values)); + return result; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setVariableNames(JNIEnv* env, + jclass, + jlong handle, + jobjectArray values) +{ + const auto h_values = get_string_array(env, values); + run_problem_operation(env, "setVariableNames", [&] { + to_problem_view(handle)->get_problem()->set_variable_names(h_values); + }); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setRowNames(JNIEnv* env, + jclass, + jlong handle, + jobjectArray values) +{ + const auto h_values = get_string_array(env, values); + run_problem_operation( + env, "setRowNames", [&] { to_problem_view(handle)->get_problem()->set_row_names(h_values); }); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_setProblemName(JNIEnv* env, + jclass, + jlong handle, + jstring value) +{ + const auto name = get_string(env, value); + run_problem_operation( + env, "setProblemName", [&] { to_problem_view(handle)->get_problem()->set_problem_name(name); }); +} + +extern "C" JNIEXPORT jdoubleArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getQuadraticObjectiveValues(JNIEnv* env, + jclass, + jlong handle) +{ + return to_double_array(env, + to_problem_view(handle)->get_problem()->get_quadratic_objective_values()); +} + +extern "C" JNIEXPORT jintArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getQuadraticObjectiveIndices(JNIEnv* env, + jclass, + jlong handle) +{ + return to_int_array(env, + to_problem_view(handle)->get_problem()->get_quadratic_objective_indices()); +} + +extern "C" JNIEXPORT jintArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getQuadraticObjectiveOffsets(JNIEnv* env, + jclass, + jlong handle) +{ + return to_int_array(env, + to_problem_view(handle)->get_problem()->get_quadratic_objective_offsets()); +} + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getVariableNames(JNIEnv* env, + jclass, + jlong handle) +{ + return to_string_array(env, to_problem_view(handle)->get_problem()->get_variable_names()); +} + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getRowNames(JNIEnv* env, + jclass, + jlong handle) +{ + return to_string_array(env, to_problem_view(handle)->get_problem()->get_row_names()); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getProblemName(JNIEnv* env, + jclass, + jlong handle) +{ + return env->NewStringUTF(to_problem_view(handle)->get_problem()->get_problem_name().c_str()); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getProblemCategory(JNIEnv* env, + jclass, + jlong handle) +{ + jint category = 0; + if (!run_problem_operation(env, "getProblemCategory", [&] { + category = + static_cast(to_problem_view(handle)->get_problem()->get_problem_category()); + })) { + return 0; + } + return category; +} + +extern "C" JNIEXPORT jobjectArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getQuadraticConstraints(JNIEnv* env, + jclass, + jlong handle) +{ + const auto& constraints = to_problem_view(handle)->get_problem()->get_quadratic_constraints(); + jclass object_class = env->FindClass("java/lang/Object"); + jobjectArray result = + env->NewObjectArray(static_cast(constraints.size()), object_class, nullptr); + for (jsize i = 0; i < static_cast(constraints.size()); ++i) { + const auto& constraint = constraints[static_cast(i)]; + jobjectArray entry = env->NewObjectArray(9, object_class, nullptr); + env->SetObjectArrayElement(entry, 0, to_int_array(env, {constraint.constraint_row_index})); + env->SetObjectArrayElement(entry, 1, env->NewStringUTF(constraint.constraint_row_name.c_str())); + env->SetObjectArrayElement(entry, 2, to_byte_array(env, {constraint.constraint_row_type})); + env->SetObjectArrayElement(entry, 3, to_double_array(env, constraint.linear_values)); + env->SetObjectArrayElement(entry, 4, to_int_array(env, constraint.linear_indices)); + env->SetObjectArrayElement(entry, 5, to_double_array(env, {constraint.rhs_value})); + env->SetObjectArrayElement(entry, 6, to_int_array(env, constraint.rows)); + env->SetObjectArrayElement(entry, 7, to_int_array(env, constraint.cols)); + env->SetObjectArrayElement(entry, 8, to_double_array(env, constraint.vals)); + env->SetObjectArrayElement(result, i, entry); + env->DeleteLocalRef(entry); + } + return result; +} + +#define DEFINE_DOUBLE_PROBLEM_GETTER(JAVA_NAME, C_NAME, COUNT_EXPR) \ + extern "C" JNIEXPORT jdoubleArray JNICALL \ + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_##JAVA_NAME( \ + JNIEnv* env, jclass, jlong handle) \ + { \ + const int count = (COUNT_EXPR); \ + std::vector values(static_cast(count)); \ + if (!check_status(env, C_NAME(to_problem(handle), values.data()), #C_NAME)) { \ + return nullptr; \ + } \ + return to_double_array(env, values); \ + } + +#define DEFINE_BYTE_PROBLEM_GETTER(JAVA_NAME, C_NAME, COUNT_EXPR) \ + extern "C" JNIEXPORT jbyteArray JNICALL \ + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_##JAVA_NAME( \ + JNIEnv* env, jclass, jlong handle) \ + { \ + const int count = (COUNT_EXPR); \ + std::vector values(static_cast(count)); \ + if (!check_status(env, C_NAME(to_problem(handle), values.data()), #C_NAME)) { \ + return nullptr; \ + } \ + return to_byte_array(env, values); \ + } + +DEFINE_DOUBLE_PROBLEM_GETTER( + getConstraintRHS, + cuOptGetConstraintRightHandSide, + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumConstraints(env, nullptr, handle)) + +DEFINE_DOUBLE_PROBLEM_GETTER( + getConstraintLowerBounds, + cuOptGetConstraintLowerBounds, + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumConstraints(env, nullptr, handle)) + +DEFINE_DOUBLE_PROBLEM_GETTER( + getConstraintUpperBounds, + cuOptGetConstraintUpperBounds, + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumConstraints(env, nullptr, handle)) + +DEFINE_DOUBLE_PROBLEM_GETTER( + getVariableLowerBounds, + cuOptGetVariableLowerBounds, + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumVariables(env, nullptr, handle)) + +DEFINE_DOUBLE_PROBLEM_GETTER( + getVariableUpperBounds, + cuOptGetVariableUpperBounds, + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumVariables(env, nullptr, handle)) + +DEFINE_BYTE_PROBLEM_GETTER( + getConstraintSense, + cuOptGetConstraintSense, + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumConstraints(env, nullptr, handle)) +DEFINE_BYTE_PROBLEM_GETTER( + getVariableTypes, + cuOptGetVariableTypes, + Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getNumVariables(env, nullptr, handle)) + +#undef DEFINE_DOUBLE_PROBLEM_GETTER +#undef DEFINE_BYTE_PROBLEM_GETTER + +extern "C" JNIEXPORT jlong JNICALL Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_solve( + JNIEnv* env, jclass, jlong problem_handle, jlong settings_handle) +{ + cuOptSolution solution = nullptr; + if (!check_status(env, + cuOptSolve(to_problem(problem_handle), to_settings(settings_handle), &solution), + "cuOptSolve")) { + return 0; + } + return from_handle(solution); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_destroySolution(JNIEnv*, + jclass, + jlong handle) +{ + if (handle == 0) { return; } + cuOptSolution solution = to_solution(handle); + cuOptDestroySolution(&solution); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getTerminationStatus(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_int_t value = 0; + check_status( + env, cuOptGetTerminationStatus(to_solution(handle), &value), "cuOptGetTerminationStatus"); + return value; +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getErrorStatus(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_int_t value = 0; + check_status(env, cuOptGetErrorStatus(to_solution(handle), &value), "cuOptGetErrorStatus"); + return value; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getErrorString(JNIEnv* env, + jclass, + jlong handle) +{ + char buffer[1024] = {}; + if (!check_status(env, + cuOptGetErrorString(to_solution(handle), buffer, sizeof(buffer)), + "cuOptGetErrorString")) { + return nullptr; + } + return env->NewStringUTF(buffer); +} + +extern "C" JNIEXPORT jdoubleArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getPrimalSolution(JNIEnv* env, + jclass, + jlong handle, + jint size) +{ + std::vector values(static_cast(size)); + const cuopt_int_t status = cuOptGetPrimalSolution(to_solution(handle), values.data()); + // The solution carries no primal values when the solve did not produce any, an infeasible + // problem for instance. This layer always passes a live handle and a correctly sized buffer, + // so CUOPT_INVALID_ARGUMENT can only mean the values are absent. Report that as an empty + // array, which is how the Java side already distinguishes "unavailable" from a real result. + if (status == CUOPT_INVALID_ARGUMENT) { return to_double_array(env, {}); } + if (!check_status(env, status, "cuOptGetPrimalSolution")) { return nullptr; } + return to_double_array(env, values); +} + +extern "C" JNIEXPORT jint JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getDualSolutionSize(JNIEnv* env, + jclass, + jlong handle) +{ + auto* solution = to_lp_solution(env, handle, "getDualSolution"); + if (solution == nullptr) { return 0; } + return solution->get_dual_solution_size(); +} + +extern "C" JNIEXPORT jdoubleArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getDualSolution(JNIEnv* env, + jclass, + jlong handle, + jint) +{ + auto* solution = to_lp_solution(env, handle, "getDualSolution"); + if (solution == nullptr) { return nullptr; } + try { + return to_double_array(env, solution->get_dual_solution_host()); + } catch (const std::exception& e) { + throw_cuopt_exception( + env, CUOPT_INVALID_ARGUMENT, std::string("getDualSolution failed: ") + e.what()); + return nullptr; + } +} + +extern "C" JNIEXPORT jdoubleArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getReducedCosts(JNIEnv* env, + jclass, + jlong handle, + jint) +{ + auto* solution = to_lp_solution(env, handle, "getReducedCost"); + if (solution == nullptr) { return nullptr; } + try { + return to_double_array(env, solution->get_reduced_cost_host()); + } catch (const std::exception& e) { + throw_cuopt_exception( + env, CUOPT_INVALID_ARGUMENT, std::string("getReducedCost failed: ") + e.what()); + return nullptr; + } +} + +extern "C" JNIEXPORT jdouble JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getObjectiveValue(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_float_t value = 0; + check_status(env, cuOptGetObjectiveValue(to_solution(handle), &value), "cuOptGetObjectiveValue"); + return value; +} + +extern "C" JNIEXPORT jdouble JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getDualObjectiveValue(JNIEnv* env, + jclass, + jlong handle) +{ + auto* solution = to_lp_solution(env, handle, "getDualObjective"); + if (solution == nullptr) { return 0; } + try { + return solution->get_dual_objective_value(0); + } catch (const std::exception& e) { + throw_cuopt_exception( + env, CUOPT_INVALID_ARGUMENT, std::string("getDualObjective failed: ") + e.what()); + return 0; + } +} + +extern "C" JNIEXPORT jdouble JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getSolveTime(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_float_t value = 0; + check_status(env, cuOptGetSolveTime(to_solution(handle), &value), "cuOptGetSolveTime"); + return value; +} + +extern "C" JNIEXPORT jdouble JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getMIPGap(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_float_t value = 0; + check_status(env, cuOptGetMIPGap(to_solution(handle), &value), "cuOptGetMIPGap"); + return value; +} + +extern "C" JNIEXPORT jdouble JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getSolutionBound(JNIEnv* env, + jclass, + jlong handle) +{ + cuopt_float_t value = 0; + check_status(env, cuOptGetSolutionBound(to_solution(handle), &value), "cuOptGetSolutionBound"); + return value; +} + +namespace { + +// Reads a run of scalar solution attributes into one double[] for the Java stats records. +// Ints are widened to double so both kinds travel in a single array; the Java side narrows +// the entries it knows are integral. +jdoubleArray solution_attributes(JNIEnv* env, + jlong handle, + const char* operation, + std::initializer_list float_attributes, + std::initializer_list int_attributes) +{ + std::vector values; + values.reserve(float_attributes.size() + int_attributes.size()); + for (cuopt_int_t attribute : float_attributes) { + cuopt_float_t value = 0; + if (!check_status( + env, cuOptGetSolutionFloatAttribute(to_solution(handle), attribute, &value), operation)) { + return nullptr; + } + values.push_back(value); + } + for (cuopt_int_t attribute : int_attributes) { + cuopt_int_t value = 0; + if (!check_status( + env, cuOptGetSolutionIntAttribute(to_solution(handle), attribute, &value), operation)) { + return nullptr; + } + values.push_back(static_cast(value)); + } + return to_double_array(env, values); +} + +} // namespace + +extern "C" JNIEXPORT jdoubleArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getLPStats(JNIEnv* env, + jclass, + jlong handle) +{ + return solution_attributes( + env, + handle, + "cuOptGetSolutionAttribute(LP)", + {CUOPT_SOLUTION_ATTR_LP_PRIMAL_RESIDUAL, + CUOPT_SOLUTION_ATTR_LP_DUAL_RESIDUAL, + CUOPT_SOLUTION_ATTR_LP_GAP}, + {CUOPT_SOLUTION_ATTR_LP_NUM_ITERATIONS, CUOPT_SOLUTION_ATTR_LP_SOLVED_BY}); +} + +extern "C" JNIEXPORT jdoubleArray JNICALL +Java_com_nvidia_cuopt_mathematicalprogramming_NativeCuOpt_getMIPStats(JNIEnv* env, + jclass, + jlong handle) +{ + return solution_attributes( + env, + handle, + "cuOptGetSolutionAttribute(MIP)", + {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_SOLUTION_ATTR_MIP_NUM_NODES, CUOPT_SOLUTION_ATTR_MIP_NUM_SIMPLEX_ITERATIONS}); +} diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/NativeIntegrationTest.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/NativeIntegrationTest.java new file mode 100644 index 0000000000..aaedd6383e --- /dev/null +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/NativeIntegrationTest.java @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +final class NativeIntegrationTest { + + + @Test + void settingsExposeTypedValues() { + NativeTestSupport.assumeNativeLibrary(); + try (SolverSettings settings = new SolverSettings()) { + settings.setSetting(CuOptConstants.CUOPT_LOG_TO_CONSOLE, false); + settings.setSetting(CuOptConstants.CUOPT_TIME_LIMIT, 12.5); + settings.setOptimalityTolerance(1.0e-6); + assertEquals( + Boolean.FALSE, + settings.getSetting(CuOptConstants.CUOPT_LOG_TO_CONSOLE, Boolean.class)); + assertEquals( + 12.5, + settings.getSetting(CuOptConstants.CUOPT_TIME_LIMIT, Double.class), + 1e-12); + assertEquals( + 1.0e-6, + settings.getSetting(CuOptConstants.CUOPT_ABSOLUTE_PRIMAL_TOLERANCE, Double.class), + 1e-12); + assertEquals( + 12.5, + Double.parseDouble(settings.getSettingAsString(CuOptConstants.CUOPT_TIME_LIMIT)), + 1e-12); + MIPSolutionCallback callback = (solution, objective, bound, userData) -> {}; + settings.setMIPCallback(callback, "test-user-data", 2); + assertTrue(settings.getMIPCallbacks().contains(callback)); + } + } + + @Test + void solvesSmallLPAndReportsStats() { + NativeTestSupport.assumeNativeLibrary(); + NativeTestSupport.assumeCudaDriverAvailable(); + try (Problem problem = tinyLP(); + SolverSettings settings = new SolverSettings().setMethod(SolverMethod.PDLP); + Solution solution = problem.solve(settings)) { + assertFalse(solution.isMIP()); + assertEquals(TerminationStatus.OPTIMAL, solution.getTerminationStatus()); + assertEquals(1.0, solution.getPrimalObjective(), 1e-3); + double[] primal = solution.getPrimalSolution(); + assertEquals(1.0, primal[0] + primal[1], 1e-3); + assertDoesNotThrow(solution::getLPStats); + assertThrows(IllegalStateException.class, solution::getMIPStats); + } + } + + @Test + void warmStartsPDLPFromInitialPrimalAndDualSolutions() { + NativeTestSupport.assumeNativeLibrary(); + NativeTestSupport.assumeCudaDriverAvailable(); + // tinyLP has two variables and one constraint; the optimum lies on x0 + x1 == 1. + try (Problem problem = tinyLP(); + SolverSettings settings = + new SolverSettings() + .setMethod(SolverMethod.PDLP) + .setInitialPrimalSolution(new double[] {0.5, 0.5}) + .setInitialDualSolution(new double[] {1.0}); + Solution solution = problem.solve(settings)) { + assertEquals(TerminationStatus.OPTIMAL, solution.getTerminationStatus()); + assertEquals(1.0, solution.getPrimalObjective(), 1e-3); + } + } + + @Test + void solvesProblemApiMIPAndLifecycleCloseIsIdempotent() { + NativeTestSupport.assumeNativeLibrary(); + NativeTestSupport.assumeCudaDriverAvailable(); + Problem problem = new Problem("integer"); + Variable x = problem.addVariable(0, 10, 1.0, VariableType.INTEGER, "x"); + problem.addConstraint(LinearExpression.of(x).ge(1.0)); + + try (SolverSettings settings = new SolverSettings().setSetting(CuOptConstants.CUOPT_TIME_LIMIT, 10.0); + Solution solution = problem.solve(settings)) { + assertTrue(solution.isMIP()); + assertEquals(TerminationStatus.OPTIMAL, solution.getTerminationStatus()); + assertEquals(1.0, x.getValue(), 1e-6); + assertDoesNotThrow(solution::getMIPStats); + assertThrows(IllegalStateException.class, solution::getDualSolution); + solution.close(); + solution.close(); + } + } + + @Test + void solvesSmallQP() { + NativeTestSupport.assumeNativeLibrary(); + NativeTestSupport.assumeCudaDriverAvailable(); + try (Problem problem = tinyLP()) { + Variable x0 = problem.getVariable(0); + Variable x1 = problem.getVariable(1); + problem.setObjective( + QuadraticExpression.of(x0, x0, 1.0).plus(x1, x1, 4.0), + ObjectiveSense.MINIMIZE); + try (SolverSettings settings = new SolverSettings().setSetting(CuOptConstants.CUOPT_ITERATION_LIMIT, 50); + Solution solution = problem.solve(settings)) { + assertFalse(solution.isMIP()); + assertDoesNotThrow(solution::getPrimalSolution); + } + } + } + + @Test + void rejectsMissingFileThroughCuOptException() { + NativeTestSupport.assumeNativeLibrary(); + CuOptException exception = + assertThrows(CuOptException.class, () -> Problem.read("missing-file-does-not-exist.mps")); + assertEquals(CuOptConstants.CUOPT_MPS_FILE_ERROR, exception.getStatusCode()); + } + + @Test + void writesAndReadsProblemFiles() throws Exception { + NativeTestSupport.assumeNativeLibrary(); + NativeTestSupport.assumeCudaDriverAvailable(); + Path file = Files.createTempFile("cuopt-java-roundtrip-", ".mps"); + try { + try (Problem source = tinyLP()) { + source.write(file.toString()); + } + // The extension drives the parser; the boolean overload forces fixed-format MPS. + try (Problem read = Problem.read(file.toString()); + Problem fixedFormat = Problem.read(file.toString(), false)) { + assertEquals(2, read.getNumVariables()); + assertEquals(1, read.getNumConstraints()); + assertEquals(read.getNumVariables(), fixedFormat.getNumVariables()); + assertEquals(read.getNumConstraints(), fixedFormat.getNumConstraints()); + } + } finally { + Files.deleteIfExists(file); + } + } + + private static Problem tinyLP() { + Problem problem = new Problem("tiny"); + Variable x0 = problem.addVariable(0.0, Double.POSITIVE_INFINITY, 1.0, VariableType.CONTINUOUS, "x0"); + Variable x1 = problem.addVariable(0.0, Double.POSITIVE_INFINITY, 1.0, VariableType.CONTINUOUS, "x1"); + problem.addConstraint(LinearExpression.of(x0).plus(x1).ge(1.0), "c0"); + problem.setObjective(LinearExpression.of(x0).plus(x1), ObjectiveSense.MINIMIZE); + return problem; + } + +} diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/NativeTestSupport.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/NativeTestSupport.java new file mode 100644 index 0000000000..3296eb4f72 --- /dev/null +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/NativeTestSupport.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Assumptions; + +final class NativeTestSupport { + private NativeTestSupport() {} + + static void assumeNativeLibrary() { + String nativeDir = System.getProperty("cuopt.native.dir"); + Assumptions.assumeTrue(nativeDir != null && !nativeDir.isBlank(), "cuopt.native.dir is unset"); + Assumptions.assumeTrue( + Files.exists(Path.of(nativeDir, System.mapLibraryName("cuopt_jni"))), + "libcuopt_jni is not built"); + } + + private static final long NVIDIA_SMI_TIMEOUT_SECONDS = 30; + + static void assumeCudaDriverAvailable() { + Process process = null; + // The assumptions are made after the try block on purpose. Assumptions.assumeTrue signals a + // skip by throwing TestAbortedException, which a catch here would swallow and re-report under + // the wrong reason. + boolean exited = false; + int exitCode = -1; + try { + // Discard the output rather than leaving it in the pipe: nothing reads it, and a full + // pipe buffer would block nvidia-smi instead of letting it exit. + process = + new ProcessBuilder("nvidia-smi") + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start(); + // A wedged driver makes nvidia-smi hang indefinitely, which would hang the whole suite. + exited = process.waitFor(NVIDIA_SMI_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (exited) { + exitCode = process.exitValue(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Assumptions.abort("CUDA driver check was interrupted"); + } catch (IOException | SecurityException e) { + Assumptions.abort("CUDA driver check failed: " + e.getMessage()); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + + Assumptions.assumeTrue( + exited, "CUDA driver check timed out after " + NVIDIA_SMI_TIMEOUT_SECONDS + "s"); + Assumptions.assumeTrue(exitCode == 0, "CUDA driver is unavailable"); + } +} diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/ProblemIntegrationTest.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/ProblemIntegrationTest.java new file mode 100644 index 0000000000..cdd85e7243 --- /dev/null +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/ProblemIntegrationTest.java @@ -0,0 +1,727 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +final class ProblemIntegrationTest { + private static final double SOLVE_TOLERANCE = 1.0e-3; + + @TestFactory + Stream problemsBuildAndSolve() { + return cases().stream() + .map(testCase -> DynamicTest.dynamicTest(testCase.name, () -> verify(testCase))); + } + + private static void verify(CaseSpec testCase) { + NativeTestSupport.assumeNativeLibrary(); + NativeTestSupport.assumeCudaDriverAvailable(); + + try (Problem problem = testCase.createProblem()) { + assertProblemConstruction(testCase, problem); + if (!testCase.shouldSolve()) { + // QP callability is covered by NativeIntegrationTest. This case owns the independent + // Problem construction contract for quadratic objectives and constraints. + return; + } + try (SolverSettings settings = createSettings(testCase); + Solution solution = problem.solve(settings)) { + assertSolution(testCase, problem, solution); + } + } + } + + private static void assertProblemConstruction(CaseSpec testCase, Problem problem) { + assertEquals(testCase.numVariables, problem.getNumVariables()); + assertEquals(testCase.problemConstraintCount(), problem.getNumConstraints()); + assertEquals(testCase.linearProblemConstraintCount(), problem.getConstraintMatrix().getRowOffsets().length - 1); + assertEquals(testCase.objectiveSense, problem.getObjectiveSense()); + assertEquals(testCase.objectiveOffset, problem.getObjectiveConstant(), 0.0); + assertEquals(testCase.problemName, problem.getName()); + + for (int i = 0; i < testCase.numVariables; i++) { + Variable variable = problem.getVariable(i); + assertEquals(testCase.variableLowerBounds[i], variable.getLowerBound(), 0.0); + assertEquals(testCase.variableUpperBounds[i], variable.getUpperBound(), 0.0); + assertEquals(VariableType.fromNative(testCase.variableTypes[i]), variable.getVariableType()); + assertEquals(testCase.objectiveCoefficients[i], variable.getObjectiveCoefficient(), 0.0); + assertEquals(testCase.variableName(i), variable.getVariableName()); + } + + if (!testCase.isRanged()) { + CSRMatrix matrix = problem.getConstraintMatrix(); + assertArrayEquals(testCase.rowOffsets, matrix.getRowOffsets()); + assertArrayEquals(testCase.columnIndices, matrix.getColumnIndices()); + assertDoubleArrayEquals(testCase.values, matrix.getValues(), 0.0); + for (int row = 0; row < testCase.numConstraints; row++) { + Constraint constraint = problem.getConstraint(row); + assertEquals(ConstraintSense.fromNative(testCase.constraintSense[row]), constraint.getSense()); + assertEquals(testCase.rhs[row], constraint.getRHS(), 0.0); + assertEquals(testCase.rowName(row), constraint.getConstraintName()); + } + } + + if (testCase.hasQuadraticObjective()) { + assertNotNull(problem.getQuadraticObjectiveMatrix()); + CSRMatrix matrix = problem.getQuadraticObjectiveMatrix(); + assertArrayEquals(testCase.quadraticObjectiveRowOffsets, matrix.getRowOffsets()); + assertArrayEquals(testCase.quadraticObjectiveColumnIndices, matrix.getColumnIndices()); + assertDoubleArrayEquals(testCase.quadraticObjectiveValues, matrix.getValues(), 0.0); + } + + List quadraticConstraints = problem.getQuadraticConstraints(); + assertEquals(testCase.hasQuadraticConstraint() ? 1 : 0, quadraticConstraints.size()); + if (testCase.hasQuadraticConstraint()) { + Constraint constraint = quadraticConstraints.get(0); + assertEquals(testCase.quadraticConstraintName, constraint.getConstraintName()); + assertEquals(ConstraintSense.fromNative(testCase.quadraticConstraintSense), constraint.getSense()); + assertEquals(testCase.quadraticConstraintRHS, constraint.getRHS(), 0.0); + } + } + + private static void assertSolution(CaseSpec testCase, Problem problem, Solution solution) { + assertEquals(testCase.hasIntegerVariables(), solution.isMIP()); + // getStatus is the way to tell a solved problem from an unsolved one: it stays + // NO_TERMINATION until a solve populates it. + assertNotEquals(TerminationStatus.NO_TERMINATION, problem.getStatus()); + assertEquals(solution.getTerminationStatus(), problem.getStatus()); + assertTrue( + Double.isNaN(solution.getSolveTime()) || solution.getSolveTime() >= 0.0, + "solve time must be non-negative when available"); + + if (!testCase.expectSolutionValues) { + assertTrue( + solution.getTerminationStatus() == TerminationStatus.INFEASIBLE + || solution.getTerminationStatus() == TerminationStatus.UNBOUNDED_OR_INFEASIBLE, + "expected an infeasible status, got " + solution.getTerminationStatus()); + for (Variable variable : problem.getVariables()) { + assertTrue(Double.isNaN(variable.getReducedCost())); + } + for (Constraint constraint : problem.getConstraints()) { + assertTrue(Double.isNaN(constraint.getDualValue())); + } + return; + } + + assertEquals(TerminationStatus.OPTIMAL, solution.getTerminationStatus()); + double[] primal = solution.getPrimalSolution(); + assertEquals(testCase.numVariables, primal.length); + testCase.assertFeasible(primal); + + if (!Double.isNaN(testCase.expectedObjective)) { + assertEquals( + testCase.expectedObjective, + solution.getPrimalObjective(), + testCase.solutionTolerance, + "objective value"); + } + + if (testCase.hasIntegerVariables()) { + assertDoesNotThrow(solution::getMIPStats); + assertThrows(IllegalStateException.class, solution::getDualSolution); + assertThrows(IllegalStateException.class, solution::getLPStats); + } else { + assertDoesNotThrow(solution::getDualSolution); + assertDoesNotThrow(solution::getReducedCost); + assertDoesNotThrow(solution::getLPStats); + assertThrows(IllegalStateException.class, solution::getMIPStats); + } + } + + private static SolverSettings createSettings(CaseSpec testCase) { + SolverSettings settings = new SolverSettings(); + settings.setSetting(CuOptConstants.CUOPT_LOG_TO_CONSOLE, false); + settings.setSetting(CuOptConstants.CUOPT_TIME_LIMIT, 30.0); + settings.setSetting(CuOptConstants.CUOPT_RANDOM_SEED, 1); + if (testCase.hasIntegerVariables()) { + settings.setSetting( + CuOptConstants.CUOPT_MIP_DETERMINISM_MODE, CuOptConstants.CUOPT_MODE_DETERMINISTIC); + settings.setSetting(CuOptConstants.CUOPT_MIP_ABSOLUTE_GAP, 1.0e-8); + settings.setSetting(CuOptConstants.CUOPT_MIP_RELATIVE_GAP, 1.0e-8); + } else if (testCase.hasQuadraticObjective()) { + settings.setSetting(CuOptConstants.CUOPT_ITERATION_LIMIT, 50); + } else { + settings.setMethod(SolverMethod.PDLP); + settings.setPDLPSolverMode(PDLPSolverMode.STABLE1); + settings.setSetting(CuOptConstants.CUOPT_ABSOLUTE_PRIMAL_TOLERANCE, 1.0e-7); + settings.setSetting(CuOptConstants.CUOPT_RELATIVE_PRIMAL_TOLERANCE, 1.0e-7); + settings.setSetting(CuOptConstants.CUOPT_ABSOLUTE_DUAL_TOLERANCE, 1.0e-7); + settings.setSetting(CuOptConstants.CUOPT_RELATIVE_DUAL_TOLERANCE, 1.0e-7); + settings.setSetting(CuOptConstants.CUOPT_ABSOLUTE_GAP_TOLERANCE, 1.0e-7); + settings.setSetting(CuOptConstants.CUOPT_RELATIVE_GAP_TOLERANCE, 1.0e-7); + } + return settings; + } + + private static void assertDoubleArrayEquals( + double[] expected, double[] actual, double tolerance) { + assertEquals(expected.length, actual.length, "array length"); + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], actual[i], tolerance, "array value at index " + i); + } + } + + private static List cases() { + return List.of( + new CaseSpec( + "lp_min_ge_unique_solution", + 1, + 2, + ObjectiveSense.MINIMIZE, + 0.25, + new double[] {1.0, 2.0}, + new int[] {0, 2}, + new int[] {0, 1}, + new double[] {1.0, 1.0}, + new byte[] {'G'}, + new double[] {3.0}, + null, + null, + new double[] {0.0, 0.0}, + new double[] {10.0, 10.0}, + new byte[] {'C', 'C'}, + true, + 3.25), + new CaseSpec( + "lp_max_le_unique_solution", + 3, + 2, + ObjectiveSense.MAXIMIZE, + -1.0, + new double[] {3.0, 2.0}, + new int[] {0, 2, 3, 4}, + new int[] {0, 1, 0, 1}, + new double[] {1.0, 1.0, 1.0, 1.0}, + new byte[] {'L', 'L', 'L'}, + new double[] {4.0, 2.0, 3.0}, + null, + null, + new double[] {0.0, 0.0}, + new double[] {10.0, 10.0}, + new byte[] {'C', 'C'}, + true, + 9.0), + new CaseSpec( + "lp_equal_with_offset", + 1, + 2, + ObjectiveSense.MINIMIZE, + 7.0, + new double[] {0.0, 1.0}, + new int[] {0, 2}, + new int[] {0, 1}, + new double[] {1.0, 1.0}, + new byte[] {'E'}, + new double[] {5.0}, + null, + null, + new double[] {0.0, 0.0}, + new double[] {5.0, 5.0}, + new byte[] {'C', 'C'}, + true, + 7.0), + new CaseSpec( + "lp_ranged_bounds", + 2, + 2, + ObjectiveSense.MINIMIZE, + 0.0, + new double[] {0.2, 1.0}, + new int[] {0, 2, 4}, + new int[] {0, 1, 0, 1}, + new double[] {1.0, 1.0, 2.0, 1.0}, + null, + null, + new double[] {1.0, 2.0}, + new double[] {3.0, 4.0}, + new double[] {0.0, 0.0}, + new double[] {10.0, 10.0}, + new byte[] {'C', 'C'}, + true, + 0.2), + new CaseSpec( + "lp_mixed_bounds_negative_coefficients", + 1, + 2, + ObjectiveSense.MINIMIZE, + -2.0, + new double[] {-1.0, 2.0}, + new int[] {0, 2}, + new int[] {0, 1}, + new double[] {1.0, 1.0}, + new byte[] {'E'}, + new double[] {1.0}, + null, + null, + new double[] {-2.0, -1.0}, + new double[] {2.0, 3.0}, + new byte[] {'C', 'C'}, + true, + -6.0), + new CaseSpec( + "lp_max_ranged_bounds", + 1, + 2, + ObjectiveSense.MAXIMIZE, + 0.0, + new double[] {2.0, 1.0}, + new int[] {0, 2}, + new int[] {0, 1}, + new double[] {1.0, 1.0}, + null, + null, + new double[] {0.0}, + new double[] {3.0}, + new double[] {0.0, 0.0}, + new double[] {2.0, 2.0}, + new byte[] {'C', 'C'}, + true, + 5.0), + new CaseSpec( + "milp_integer_unique_solution", + 1, + 2, + ObjectiveSense.MINIMIZE, + 0.0, + new double[] {1.0, 2.0}, + new int[] {0, 2}, + new int[] {0, 1}, + new double[] {1.0, 1.0}, + new byte[] {'G'}, + new double[] {2.5}, + null, + null, + new double[] {0.0, 0.0}, + new double[] {10.0, 10.0}, + new byte[] {'I', 'I'}, + true, + 3.0), + new CaseSpec( + "milp_mixed_integer_continuous_max", + 1, + 2, + ObjectiveSense.MAXIMIZE, + 0.0, + new double[] {5.0, 1.0}, + new int[] {0, 2}, + new int[] {0, 1}, + new double[] {1.0, 1.0}, + new byte[] {'L'}, + new double[] {2.5}, + null, + null, + new double[] {0.0, 0.0}, + new double[] {3.0, 10.0}, + new byte[] {'I', 'C'}, + true, + 10.5), + new CaseSpec( + "qp_diagonal_objective", + 1, + 2, + ObjectiveSense.MINIMIZE, + 0.0, + new double[] {-8.0, -16.0}, + new int[] {0, 2}, + new int[] {0, 1}, + new double[] {1.0, 1.0}, + null, + null, + new double[] {5.0}, + new double[] {1.0e20}, + new double[] {0.0, 0.0}, + new double[] {10.0, 10.0}, + new byte[] {'C', 'C'}, + true, + Double.NaN) + .withQuadraticObjective( + new int[] {0, 1, 2}, new int[] {0, 1}, new double[] {1.0, 4.0}) + .withMetadata( + new String[] {"x0", "long_variable_1"}, + new String[] {"constraint_0"}, + "qp_model") + .withQuadraticConstraint( + "qc0", + (byte) 'L', + 100.0, + new double[] {1.0}, + new int[] {0}, + new double[] {1.0}, + new int[] {0}, + new int[] {0}) + .withoutSolve(), + new CaseSpec( + "lp_infeasible_status", + 2, + 1, + ObjectiveSense.MINIMIZE, + 0.0, + new double[] {1.0}, + new int[] {0, 1, 2}, + new int[] {0, 0}, + new double[] {1.0, 1.0}, + new byte[] {'G', 'L'}, + new double[] {1.0, 0.0}, + null, + null, + new double[] {0.0}, + new double[] {10.0}, + new byte[] {'C'}, + false, + Double.NaN)); + } + + private static final class CaseSpec { + private final String name; + private final int numConstraints; + private final int numVariables; + private final ObjectiveSense objectiveSense; + private final double objectiveOffset; + private final double[] objectiveCoefficients; + private final int[] rowOffsets; + private final int[] columnIndices; + private final double[] values; + private final byte[] constraintSense; + private final double[] rhs; + private final double[] constraintLowerBounds; + private final double[] constraintUpperBounds; + private final double[] variableLowerBounds; + private final double[] variableUpperBounds; + private final byte[] variableTypes; + private final boolean expectSolutionValues; + private final double expectedObjective; + private final double solutionTolerance = SOLVE_TOLERANCE; + private String[] variableNames = new String[0]; + private String[] rowNames = new String[0]; + private String problemName = ""; + private String quadraticConstraintName; + private byte quadraticConstraintSense; + private double quadraticConstraintRHS; + private double[] quadraticConstraintLinearValues; + private int[] quadraticConstraintLinearIndices; + private double[] quadraticConstraintValues; + private int[] quadraticConstraintRows; + private int[] quadraticConstraintColumns; + private int[] quadraticObjectiveRowOffsets; + private int[] quadraticObjectiveColumnIndices; + private double[] quadraticObjectiveValues; + private boolean solveCase = true; + + private CaseSpec( + String name, + int numConstraints, + int numVariables, + ObjectiveSense objectiveSense, + double objectiveOffset, + double[] objectiveCoefficients, + int[] rowOffsets, + int[] columnIndices, + double[] values, + byte[] constraintSense, + double[] rhs, + double[] constraintLowerBounds, + double[] constraintUpperBounds, + double[] variableLowerBounds, + double[] variableUpperBounds, + byte[] variableTypes, + boolean expectSolutionValues, + double expectedObjective) { + this.name = name; + this.numConstraints = numConstraints; + this.numVariables = numVariables; + this.objectiveSense = objectiveSense; + this.objectiveOffset = objectiveOffset; + this.objectiveCoefficients = Arrays.copyOf(objectiveCoefficients, objectiveCoefficients.length); + this.rowOffsets = Arrays.copyOf(rowOffsets, rowOffsets.length); + this.columnIndices = Arrays.copyOf(columnIndices, columnIndices.length); + this.values = Arrays.copyOf(values, values.length); + this.constraintSense = + constraintSense == null ? null : Arrays.copyOf(constraintSense, constraintSense.length); + this.rhs = rhs == null ? null : Arrays.copyOf(rhs, rhs.length); + this.constraintLowerBounds = + constraintLowerBounds == null + ? null + : Arrays.copyOf(constraintLowerBounds, constraintLowerBounds.length); + this.constraintUpperBounds = + constraintUpperBounds == null + ? null + : Arrays.copyOf(constraintUpperBounds, constraintUpperBounds.length); + this.variableLowerBounds = Arrays.copyOf(variableLowerBounds, variableLowerBounds.length); + this.variableUpperBounds = Arrays.copyOf(variableUpperBounds, variableUpperBounds.length); + this.variableTypes = Arrays.copyOf(variableTypes, variableTypes.length); + this.expectSolutionValues = expectSolutionValues; + this.expectedObjective = expectedObjective; + } + + private Problem createProblem() { + Problem problem = new Problem(problemName); + for (int i = 0; i < numVariables; i++) { + problem.addVariable( + variableLowerBounds[i], + variableUpperBounds[i], + objectiveCoefficients[i], + VariableType.fromNative(variableTypes[i]), + variableName(i)); + } + + if (hasQuadraticObjective()) { + problem.setObjective(buildQuadraticObjective(problem), objectiveSense); + } else { + problem.setObjective(buildLinearObjective(problem), objectiveSense); + } + + for (int row = 0; row < numConstraints; row++) { + LinearExpression expression = buildRowExpression(problem, row); + if (isRanged()) { + if (!Double.isInfinite(constraintLowerBounds[row])) { + problem.addConstraint(expression.ge(constraintLowerBounds[row]), rangedRowName(row, "lower")); + } + if (!Double.isInfinite(constraintUpperBounds[row])) { + problem.addConstraint(expression.le(constraintUpperBounds[row]), rangedRowName(row, "upper")); + } + } else { + problem.addConstraint( + toConstraint(expression, ConstraintSense.fromNative(constraintSense[row]), rhs[row]), + rowName(row)); + } + } + + if (hasQuadraticConstraint()) { + QuadraticExpression expression = new QuadraticExpression(); + for (int i = 0; i < quadraticConstraintLinearValues.length; i++) { + expression = + expression.plus( + problem.getVariable(quadraticConstraintLinearIndices[i]), + quadraticConstraintLinearValues[i]); + } + for (int i = 0; i < quadraticConstraintValues.length; i++) { + expression = + expression.plus( + problem.getVariable(quadraticConstraintRows[i]), + problem.getVariable(quadraticConstraintColumns[i]), + quadraticConstraintValues[i]); + } + Constraint constraint = + ConstraintSense.fromNative(quadraticConstraintSense) == ConstraintSense.LE + ? expression.le(quadraticConstraintRHS) + : expression.ge(quadraticConstraintRHS); + problem.addConstraint(constraint, quadraticConstraintName); + } + return problem; + } + + private LinearExpression buildLinearObjective(Problem problem) { + LinearExpression objective = LinearExpression.ofConstant(objectiveOffset); + for (int i = 0; i < objectiveCoefficients.length; i++) { + if (objectiveCoefficients[i] != 0.0) { + objective = objective.plus(problem.getVariable(i), objectiveCoefficients[i]); + } + } + return objective; + } + + private QuadraticExpression buildQuadraticObjective(Problem problem) { + QuadraticExpression objective = new QuadraticExpression().constant(objectiveOffset); + for (int i = 0; i < objectiveCoefficients.length; i++) { + if (objectiveCoefficients[i] != 0.0) { + objective = objective.plus(problem.getVariable(i), objectiveCoefficients[i]); + } + } + for (int row = 0; row + 1 < quadraticObjectiveRowOffsets.length; row++) { + for (int p = quadraticObjectiveRowOffsets[row]; p < quadraticObjectiveRowOffsets[row + 1]; p++) { + objective = + objective.plus( + problem.getVariable(row), + problem.getVariable(quadraticObjectiveColumnIndices[p]), + quadraticObjectiveValues[p]); + } + } + return objective; + } + + private LinearExpression buildRowExpression(Problem problem, int row) { + LinearExpression expression = new LinearExpression(); + for (int p = rowOffsets[row]; p < rowOffsets[row + 1]; p++) { + expression = expression.plus(problem.getVariable(columnIndices[p]), values[p]); + } + return expression; + } + + private Constraint toConstraint(LinearExpression expression, ConstraintSense sense, double rhs) { + switch (sense) { + case LE: + return expression.le(rhs); + case GE: + return expression.ge(rhs); + case EQ: + return expression.eq(rhs); + default: + throw new IllegalStateException("Unsupported sense " + sense); + } + } + + private boolean isRanged() { + return constraintLowerBounds != null; + } + + private boolean hasQuadraticObjective() { + return quadraticObjectiveValues != null; + } + + private boolean shouldSolve() { + return solveCase; + } + + private boolean hasQuadraticConstraint() { + return quadraticConstraintValues != null; + } + + private boolean hasIntegerVariables() { + for (byte type : variableTypes) { + if (type == 'I' || type == 'S') { + return true; + } + } + return false; + } + + private int linearProblemConstraintCount() { + return isRanged() ? numConstraints * 2 : numConstraints; + } + + private int problemConstraintCount() { + return linearProblemConstraintCount() + (hasQuadraticConstraint() ? 1 : 0); + } + + private String variableName(int index) { + return variableNames.length > index ? variableNames[index] : ""; + } + + private String rowName(int index) { + return rowNames.length > index ? rowNames[index] : ""; + } + + private String rangedRowName(int index, String boundName) { + String base = rowName(index); + return base.isEmpty() ? "" : base + "_" + boundName; + } + + private CaseSpec withQuadraticObjective( + int[] rowOffsets, int[] columnIndices, double[] values) { + this.quadraticObjectiveRowOffsets = Arrays.copyOf(rowOffsets, rowOffsets.length); + this.quadraticObjectiveColumnIndices = Arrays.copyOf(columnIndices, columnIndices.length); + this.quadraticObjectiveValues = Arrays.copyOf(values, values.length); + return this; + } + + private CaseSpec withMetadata( + String[] variableNames, String[] rowNames, String problemName) { + this.variableNames = Arrays.copyOf(variableNames, variableNames.length); + this.rowNames = Arrays.copyOf(rowNames, rowNames.length); + this.problemName = problemName; + return this; + } + + private CaseSpec withQuadraticConstraint( + String name, + byte sense, + double rhs, + double[] linearValues, + int[] linearIndices, + double[] values, + int[] rows, + int[] columns) { + this.quadraticConstraintName = name; + this.quadraticConstraintSense = sense; + this.quadraticConstraintRHS = rhs; + this.quadraticConstraintLinearValues = Arrays.copyOf(linearValues, linearValues.length); + this.quadraticConstraintLinearIndices = Arrays.copyOf(linearIndices, linearIndices.length); + this.quadraticConstraintValues = Arrays.copyOf(values, values.length); + this.quadraticConstraintRows = Arrays.copyOf(rows, rows.length); + this.quadraticConstraintColumns = Arrays.copyOf(columns, columns.length); + return this; + } + + private CaseSpec withoutSolve() { + this.solveCase = false; + return this; + } + + private void assertFeasible(double[] primal) { + for (int variable = 0; variable < numVariables; variable++) { + assertTrue( + primal[variable] >= variableLowerBounds[variable] - solutionTolerance, + "variable " + variable + " violates its lower bound"); + assertTrue( + primal[variable] <= variableUpperBounds[variable] + solutionTolerance, + "variable " + variable + " violates its upper bound"); + if (variableTypes[variable] == 'I') { + assertEquals( + Math.rint(primal[variable]), + primal[variable], + solutionTolerance, + "variable " + variable + " must be integral"); + } + } + + for (int row = 0; row < numConstraints; row++) { + double activity = 0.0; + for (int index = rowOffsets[row]; index < rowOffsets[row + 1]; index++) { + activity += values[index] * primal[columnIndices[index]]; + } + if (isRanged()) { + assertTrue( + activity >= constraintLowerBounds[row] - solutionTolerance, + "row " + row + " violates its lower bound"); + assertTrue( + activity <= constraintUpperBounds[row] + solutionTolerance, + "row " + row + " violates its upper bound"); + } else if (constraintSense[row] == 'L') { + assertTrue(activity <= rhs[row] + solutionTolerance, "row " + row + " violates <="); + } else if (constraintSense[row] == 'G') { + assertTrue(activity >= rhs[row] - solutionTolerance, "row " + row + " violates >="); + } else { + assertEquals(rhs[row], activity, solutionTolerance, "row " + row + " violates ="); + } + } + + if (hasQuadraticConstraint()) { + double activity = 0.0; + for (int i = 0; i < quadraticConstraintLinearValues.length; i++) { + activity += + quadraticConstraintLinearValues[i] + * primal[quadraticConstraintLinearIndices[i]]; + } + for (int i = 0; i < quadraticConstraintValues.length; i++) { + activity += + quadraticConstraintValues[i] + * primal[quadraticConstraintRows[i]] + * primal[quadraticConstraintColumns[i]]; + } + if (quadraticConstraintSense == 'L') { + assertTrue( + activity <= quadraticConstraintRHS + solutionTolerance, + "quadratic constraint violates <="); + } else { + assertTrue( + activity >= quadraticConstraintRHS - solutionTolerance, + "quadratic constraint violates >="); + } + } + } + } +} diff --git a/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/ProblemModelingTest.java b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/ProblemModelingTest.java new file mode 100644 index 0000000000..eab8308bfb --- /dev/null +++ b/java/cuopt/src/test/java/com/nvidia/cuopt/mathematicalprogramming/ProblemModelingTest.java @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package com.nvidia.cuopt.mathematicalprogramming; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +final class ProblemModelingTest { + @Test + void generatedSolverEnumsMatchCuOptConstants() { + assertEquals(CuOptConstants.CUOPT_METHOD_PDLP, SolverMethod.PDLP.nativeValue()); + assertEquals( + CuOptConstants.CUOPT_PDLP_SOLVER_MODE_STABLE1, + PDLPSolverMode.STABLE1.nativeValue()); + assertEquals( + CuOptConstants.CUOPT_TERMINATION_STATUS_OPTIMAL, + TerminationStatus.OPTIMAL.nativeValue()); + } + + @Test + void mapsLegacyIPCategoryToMIP() { + assertEquals(ProblemCategory.MIP, ProblemCategory.fromNative(2)); + } + + @Test + void buildsLinearProblemAndCSR() { + Problem problem = new Problem("Simple MIP"); + Variable x = problem.addVariable(0, Double.POSITIVE_INFINITY, 0, VariableType.INTEGER, "x"); + Variable y = problem.addVariable(10, 50, 0, VariableType.INTEGER, "y"); + + assertEquals(0, x.getIndex()); + assertEquals(1, y.getIndex()); + assertTrue(problem.isMIP()); + + problem.addConstraint(LinearExpression.of(x, 2).plus(y, 4).ge(230), "c1"); + problem.addConstraint(LinearExpression.of(x, 3).plus(y, 2).constant(10).le(200), "c2"); + problem.setObjective(LinearExpression.of(x, 5).plus(y, 3).constant(50), ObjectiveSense.MAXIMIZE); + + LinearExpression objective = problem.getObjective(); + assertEquals(50.0, objective.getConstant()); + + CSRMatrix csr = problem.getConstraintMatrix(); + assertArrayEquals(new int[] {0, 2, 4}, csr.getRowOffsets()); + assertArrayEquals(new int[] {0, 1, 0, 1}, csr.getColumnIndices()); + assertArrayEquals(new double[] {2.0, 4.0, 3.0, 2.0}, csr.getValues()); + + assertEquals(2, problem.getNumVariables()); + assertEquals(2, problem.getNumConstraints()); + assertEquals(230, problem.getConstraint(0).getRHS()); + assertEquals(190, problem.getConstraint(1).getRHS()); + } + + @Test + void duplicateLinearTermsAreMergedForSlack() { + Problem problem = new Problem(); + Variable x = problem.addVariable(); + Constraint constraint = problem.addConstraint(LinearExpression.of(x, 5).plus(x, 7).le(18)); + + x.setValue(1.0); + + assertEquals(12.0, constraint.getCoefficient(x)); + assertEquals(6.0, constraint.computeSlack()); + assertFalse(problem.isMIP()); + } + + @Test + void csrMatrixRejectsMalformedInputs() { + assertThrows(IllegalArgumentException.class, () -> new CSRMatrix(null, new int[0], new int[] {0})); + assertThrows(IllegalArgumentException.class, () -> new CSRMatrix(new double[0], null, new int[] {0})); + assertThrows(IllegalArgumentException.class, () -> new CSRMatrix(new double[0], new int[0], null)); + assertThrows(IllegalArgumentException.class, () -> new CSRMatrix(new double[0], new int[0], new int[0])); + assertThrows( + IllegalArgumentException.class, + () -> new CSRMatrix(new double[0], new int[0], new int[] {1})); + assertThrows( + IllegalArgumentException.class, + () -> new CSRMatrix(new double[] {1.0}, new int[] {0}, new int[] {0, 2})); + assertThrows( + IllegalArgumentException.class, + () -> new CSRMatrix(new double[] {1.0}, new int[] {0}, new int[] {0, 1, 0})); + assertThrows( + IllegalArgumentException.class, + () -> new CSRMatrix(new double[] {1.0}, new int[0], new int[] {0, 1})); + } + + @Test + void expressionDivisionRejectsZero() { + Problem problem = new Problem(); + Variable x = problem.addVariable(); + + assertThrows(IllegalArgumentException.class, () -> LinearExpression.of(x).dividedBy(0.0)); + assertThrows( + IllegalArgumentException.class, + () -> QuadraticExpression.of(x, x, 1.0).dividedBy(-0.0)); + } + + @Test + void structuralChangesClearSolvedValues() { + Problem problem = new Problem(); + Variable x = problem.addVariable(); + Constraint constraint = problem.addConstraint(LinearExpression.of(x).le(1.0)); + + x.setValue(1.0); + constraint.setSlack(0.0); + problem.addVariable(); + assertTrue(Double.isNaN(x.getValue())); + assertTrue(Double.isNaN(constraint.getSlack())); + + x.setValue(1.0); + constraint.setSlack(0.0); + problem.addConstraint(LinearExpression.of(x).ge(0.0)); + assertTrue(Double.isNaN(x.getValue())); + assertTrue(Double.isNaN(constraint.getSlack())); + } +}