Skip to content

Implement UnboundedOrInfeasible termination status#941

Open
rg20 wants to merge 4 commits intoNVIDIA:mainfrom
rg20:issue_923
Open

Implement UnboundedOrInfeasible termination status#941
rg20 wants to merge 4 commits intoNVIDIA:mainfrom
rg20:issue_923

Conversation

@rg20
Copy link
Contributor

@rg20 rg20 commented Mar 6, 2026

Description

This PR adds a new termination enum for UnboundedOrInfeasible. This status is returned by presolvers PSLP and Papilo.

Issue

Closes #923

Checklist

  • I am familiar with the Contributing Guidelines.
  • Testing
    • New or existing tests cover these changes
    • Added tests
    • Created an issue to follow-up
    • NA
  • Documentation
    • The documentation is up to date with these changes
    • Added new documentation
    • NA

@rg20 rg20 requested review from a team as code owners March 6, 2026 17:27
@copy-pr-bot
Copy link

copy-pr-bot bot commented Mar 6, 2026

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@rg20 rg20 added the bug Something isn't working label Mar 6, 2026
@rg20 rg20 added this to the 26.04 milestone Mar 6, 2026
@rg20 rg20 added the non-breaking Introduces a non-breaking change label Mar 6, 2026
@rg20
Copy link
Contributor Author

rg20 commented Mar 6, 2026

/ok to test da2b1a1

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

Adds a new termination status UnboundedOrInfeasible and threads it through constants, C++ enums, presolve result types (replacing optionals with concrete results including status and maps), solver handling, Python bindings, tests, and documentation.

Changes

Cohort / File(s) Summary
Termination Status Constant
cpp/include/cuopt/linear_programming/constants.h
Added CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE macro (value 11).
Solver Enums / Strings
cpp/include/cuopt/linear_programming/mip/solver_solution.hpp, cpp/include/cuopt/linear_programming/pdlp/solver_solution.hpp, cpp/src/mip_heuristics/solver_solution.cu, cpp/src/pdlp/solver_solution.cu
Added UnboundedOrInfeasible enum member and corresponding string mappings; adjusted formatting/alignment.
Presolve API & Types
cpp/src/mip_heuristics/presolve/third_party_presolve.hpp, cpp/src/mip_heuristics/presolve/third_party_presolve.cpp
Introduced third_party_presolve_status_t, added status and original_to_reduced_map to third_party_presolve_result_t, and changed apply/apply_pslp to return concrete result objects (removed std::optional).
Presolve Consumers (MIP/PDLP)
cpp/src/mip_heuristics/solve.cu, cpp/src/pdlp/solve.cu
Replaced optional-based handling with status-based branching; handle INFEASIBLE / UNBNDORINFEAS / UNBOUNDED distinctly and propagate maps/indices from concrete presolve result.
Presolve Helpers & Conversions
cpp/src/mip_heuristics/presolve/third_party_presolve.cpp
Added conversion helpers from external presolve statuses to third_party_presolve_status_t and used status variable to build returns instead of optionals.
Tests (C++)
cpp/tests/mip/presolve_test.cu
Updated test to use value-style access (result.reduced_problem, result.implied_integer_indices) and assert on result.status instead of optional presence.
Python Bindings
python/cuopt/cuopt/linear_programming/solver/solver.pxd, python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx
Exposed UnboundedOrInfeasible in mip_termination_status_t and pdlp_termination_status_t and mapped to Python enums (MILPTerminationStatus, LPTerminationStatus).
Python Tests
python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py
Added test_unbounded_problem asserting UnboundedOrInfeasible for an unbounded LP; minor import formatting.
Docs & Examples
docs/cuopt/source/cuopt-c/.../lp-qp-milp-c-api.rst, docs/cuopt/source/cuopt-c/.../examples/milp_mps_example.c, docs/.../examples/simple_qp_example.c
Documented new termination constant and added example string mapping "Unbounded or infeasible"; guarded primal-solution retrieval behind a has_primal_solution check.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Implement UnboundedOrInfeasible termination status' clearly and concisely summarizes the main change: adding a new termination status enum value to handle unbounded or infeasible outcomes.
Description check ✅ Passed The description explains that the PR adds a new termination enum for UnboundedOrInfeasible status returned by PSLP and Papilo presolvers, and references the closing issue #923, relating directly to the changeset.
Linked Issues check ✅ Passed The PR successfully implements the core objective from issue #923: it introduces an UnboundedOrInfeasible termination status throughout the codebase (C++, Python bindings, tests, documentation) and updates presolve handling to propagate this status instead of collapsing it to Infeasible.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the UnboundedOrInfeasible termination status as required by issue #923; no unrelated modifications were introduced beyond this objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cpp/src/mip_heuristics/presolve/third_party_presolve.cpp`:
- Around line 649-656: The early-return that maps Papilo presolve termination
misses papilo::PresolveStatus::kUnbounded: update the conditional that currently
checks kInfeasible || kUnbndOrInfeas to also include
papilo::PresolveStatus::kUnbounded and treat it the same as unbounded by mapping
to third_party_presolve_status_t::UNBNDORINFEAS before constructing the empty
optimization_problem_t and returning third_party_presolve_result_t; apply the
identical change to the matching pattern in apply_pslp() so kUnbounded is
handled the same way there.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2c6c62f9-8539-4496-8cb3-a00035724587

📥 Commits

Reviewing files that changed from the base of the PR and between 1c6f86b and d26c753.

📒 Files selected for processing (13)
  • cpp/include/cuopt/linear_programming/constants.h
  • cpp/include/cuopt/linear_programming/mip/solver_solution.hpp
  • cpp/include/cuopt/linear_programming/pdlp/solver_solution.hpp
  • cpp/src/mip_heuristics/presolve/third_party_presolve.cpp
  • cpp/src/mip_heuristics/presolve/third_party_presolve.hpp
  • cpp/src/mip_heuristics/solve.cu
  • cpp/src/mip_heuristics/solver_solution.cu
  • cpp/src/pdlp/solve.cu
  • cpp/src/pdlp/solver_solution.cu
  • cpp/tests/mip/presolve_test.cu
  • python/cuopt/cuopt/linear_programming/solver/solver.pxd
  • python/cuopt/cuopt/linear_programming/solver/solver_wrapper.pyx
  • python/cuopt/cuopt/tests/linear_programming/test_lp_solver.py

@rg20 rg20 requested review from chris-maes and removed request for akifcorduk and nguidotti March 6, 2026 17:35
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/cuopt/source/cuopt-c/lp-qp-milp/examples/milp_mps_example.c`:
- Around line 32-33: The code unconditionally calls cuOptGetObjectiveValue and
cuOptGetPrimalSolution even for non-solution termination statuses; update the
example to check the termination status (CUOPT_TERIMINATION_STATUS_OPTIMAL,
CUOPT_TERIMINATION_STATUS_PRIMAL_FEASIBLE, or
CUOPT_TERIMINATION_STATUS_FEASIBLE_FOUND) before invoking cuOptGetObjectiveValue
and cuOptGetPrimalSolution and before printing objective/primal data; leave
other termination branches (e.g.,
CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE) to print a descriptive
message without reading solution values and ensure any existing calls to
cuOptGetObjectiveValue/cuOptGetPrimalSolution are moved inside the guarded
branch.

In `@docs/cuopt/source/cuopt-c/lp-qp-milp/lp-qp-milp-c-api.rst`:
- Line 266: Add explanatory documentation for the new
CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE by supplementing the existing
doxygendefine with a short paragraph (placed above the doxygendefine or in a
nearby "Return codes" / "Termination statuses" section) that states when the
status is returned (e.g., returned by presolvers when the solver determines the
model is either unbounded or infeasible but cannot distinguish which), what that
means for the user’s model, and recommended actions (inspect model formulation,
check bounds/constraints, try presolve diagnostics or relaxation techniques);
ensure the constant name CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE
appears verbatim in the text so it’s clear which status is being described.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e46c94d9-78f5-49b9-89d7-c5f667f1c17b

📥 Commits

Reviewing files that changed from the base of the PR and between d26c753 and da2b1a1.

📒 Files selected for processing (3)
  • docs/cuopt/source/cuopt-c/lp-qp-milp/examples/milp_mps_example.c
  • docs/cuopt/source/cuopt-c/lp-qp-milp/examples/simple_qp_example.c
  • docs/cuopt/source/cuopt-c/lp-qp-milp/lp-qp-milp-c-api.rst

.. doxygendefine:: CUOPT_TERIMINATION_STATUS_PRIMAL_FEASIBLE
.. doxygendefine:: CUOPT_TERIMINATION_STATUS_FEASIBLE_FOUND
.. doxygendefine:: CUOPT_TERIMINATION_STATUS_CONCURRENT_LIMIT
.. doxygendefine:: CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add explanatory documentation for the new termination status.

The new CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE constant is documented only with a doxygendefine directive, which pulls the basic definition from the source code. However, users need context about when this status is returned and how to interpret it.

Consider adding explanatory text above the constant definition or in a dedicated section that describes:

  • When this status is returned (e.g., "Returned by presolvers when the problem is determined to be either unbounded or infeasible, but the specific outcome cannot be distinguished")
  • What it means for the user's problem
  • Recommended actions (e.g., "Review problem formulation for unboundedness or infeasibility")

As per coding guidelines: "Check if API changes (parameters, return values, errors) are documented. If PR changes public APIs without updating docs, flag as HIGH priority."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/cuopt/source/cuopt-c/lp-qp-milp/lp-qp-milp-c-api.rst` at line 266, Add
explanatory documentation for the new
CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE by supplementing the existing
doxygendefine with a short paragraph (placed above the doxygendefine or in a
nearby "Return codes" / "Termination statuses" section) that states when the
status is returned (e.g., returned by presolvers when the solver determines the
model is either unbounded or infeasible but cannot distinguish which), what that
means for the user’s model, and recommended actions (inspect model formulation,
check bounds/constraints, try presolve diagnostics or relaxation techniques);
ensure the constant name CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE
appears verbatim in the text so it’s clear which status is being described.

@rg20
Copy link
Contributor Author

rg20 commented Mar 6, 2026

/ok to test b8c02d3

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
docs/cuopt/source/cuopt-c/lp-qp-milp/examples/milp_mps_example.c (1)

101-112: ⚠️ Potential issue | 🔴 Critical

Guard output paths when no primal solution is available.

has_primal_solution only guards retrieval, but Line 120 still prints objective_value, and Line 132-Line 135 still dereference solution_values. For statuses like UNBOUNDED_OR_INFEASIBLE, this can print uninitialized data or crash.

Proposed fix
@@
-  printf("Objective value: %f\n", objective_value);
+  if (has_primal_solution) {
+    printf("Objective value: %f\n", objective_value);
+  } else {
+    printf("Objective value: N/A\n");
+  }
@@
-  printf("\nSolution: \n");
-  for (cuopt_int_t i = 0; i < num_variables; i++) {
-    printf("x%d = %f\n", i + 1, solution_values[i]);
+  if (has_primal_solution) {
+    printf("\nSolution: \n");
+    for (cuopt_int_t i = 0; i < num_variables; i++) {
+      printf("x%d = %f\n", i + 1, solution_values[i]);
+    }
+  } else {
+    printf("\nSolution: N/A for termination status %s (%d)\n",
+           termination_status_to_string(termination_status),
+           termination_status);
   }

As per coding guidelines docs/**/*: Accuracy: Verify code examples compile and run correctly.

Also applies to: 123-130

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/cuopt/source/cuopt-c/lp-qp-milp/examples/milp_mps_example.c` around
lines 101 - 112, The example prints and dereferences solution output even when
no primal solution exists: wrap any uses of objective_value and solution_values
(including the call to cuOptGetObjectiveValue and the call that populates/reads
solution_values) behind the has_primal_solution check (the variable computed
from termination_status) so that when has_primal_solution is false you skip
printing objective_value and accessing solution_values (or print a clear "no
primal solution" message); ensure you only call cuOptGetObjectiveValue and the
function that gets solution values when has_primal_solution is true and
handle/report errors from those calls as currently done.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cpp/src/mip_heuristics/presolve/third_party_presolve.cpp`:
- Around line 479-490: The conversion functions
convert_papilo_presolve_status_to_third_party_presolve_status and
convert_pslp_presolve_status_to_third_party_presolve_status currently have
switches with no default, which can lead to UB if an unexpected enum value
appears; add a default branch to each switch that handles unknown values (e.g.,
assert or log an error) and returns a safe fallback
third_party_presolve_status_t (such as UNCHANGED or a designated UNKNOWN/ERROR
value) so the function always returns a valid enum and does not fall off the
end.

---

Duplicate comments:
In `@docs/cuopt/source/cuopt-c/lp-qp-milp/examples/milp_mps_example.c`:
- Around line 101-112: The example prints and dereferences solution output even
when no primal solution exists: wrap any uses of objective_value and
solution_values (including the call to cuOptGetObjectiveValue and the call that
populates/reads solution_values) behind the has_primal_solution check (the
variable computed from termination_status) so that when has_primal_solution is
false you skip printing objective_value and accessing solution_values (or print
a clear "no primal solution" message); ensure you only call
cuOptGetObjectiveValue and the function that gets solution values when
has_primal_solution is true and handle/report errors from those calls as
currently done.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 589ee1a2-61a7-48ef-bcb0-f9107a028231

📥 Commits

Reviewing files that changed from the base of the PR and between da2b1a1 and b8c02d3.

📒 Files selected for processing (5)
  • cpp/src/mip_heuristics/presolve/third_party_presolve.cpp
  • cpp/src/mip_heuristics/presolve/third_party_presolve.hpp
  • cpp/src/mip_heuristics/solve.cu
  • cpp/src/pdlp/solve.cu
  • docs/cuopt/source/cuopt-c/lp-qp-milp/examples/milp_mps_example.c

@rg20
Copy link
Contributor Author

rg20 commented Mar 6, 2026

/ok to test 165854c

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cpp/src/mip_heuristics/presolve/third_party_presolve.cpp`:
- Around line 612-624: The early-return branches that construct empty
third_party_presolve_result_t (when ctx.status is INFEASIBLE/UNBNDORINFEAS and
the terminal Papilo return around 676-681) do not clear the instance-level
cached mappings/postsolve payloads (pslp_presolver_, pslp_stgs_, and the object
caches exposed by get_reduced_to_original_map()/get_original_to_reduced_map()),
causing stale mappings on reuse; fix by explicitly resetting/clearing
pslp_presolver_, pslp_stgs_ and any internal mapping/postsolve containers (or
calling the presolver's clear/reset method) before returning the empty
third_party_presolve_result_t so mappings and postsolve state are consistent for
subsequent calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ffb9b675-6e8a-411a-b593-3ab6ac0f0bc5

📥 Commits

Reviewing files that changed from the base of the PR and between b8c02d3 and 165854c.

📒 Files selected for processing (1)
  • cpp/src/mip_heuristics/presolve/third_party_presolve.cpp

Comment on lines 612 to +624
pslp_presolver_ = ctx.presolver;
pslp_stgs_ = ctx.settings;

auto status = convert_pslp_presolve_status_to_third_party_presolve_status(ctx.status);
if (ctx.status == PresolveStatus_::INFEASIBLE || ctx.status == PresolveStatus_::UNBNDORINFEAS) {
return std::nullopt;
optimization_problem_t<i_t, f_t> empty_problem(op_problem.get_handle_ptr());
return third_party_presolve_result_t<i_t, f_t>{status, std::move(empty_problem), {}, {}, {}};
}

auto opt_problem = build_optimization_problem_from_pslp<i_t, f_t>(
pslp_presolver_, op_problem.get_handle_ptr(), maximize_, original_obj_offset);

return std::make_optional(third_party_presolve_result_t<i_t, f_t>{opt_problem, {}});
return third_party_presolve_result_t<i_t, f_t>{status, std::move(opt_problem), {}, {}, {}};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset cached mapping/postsolve state on PSLP and terminal Papilo returns.

These branches return empty maps in third_party_presolve_result_t, but the object-level caches exposed by get_reduced_to_original_map() / get_original_to_reduced_map() in cpp/src/mip_heuristics/presolve/third_party_presolve.hpp:79-80 are left untouched. If the same third_party_presolve_t instance is reused after a prior Papilo solve, callers can observe stale index mappings, and the Papilo terminal branch also keeps the previous postsolve payload alive for undo().

Suggested fix
   pslp_presolver_ = ctx.presolver;
   pslp_stgs_      = ctx.settings;
+  papilo_post_solve_storage_.reset();
+  reduced_to_original_map_.clear();
+  original_to_reduced_map_.clear();

   auto status = convert_pslp_presolve_status_to_third_party_presolve_status(ctx.status);
   if (ctx.status == PresolveStatus_::INFEASIBLE || ctx.status == PresolveStatus_::UNBNDORINFEAS) {
     optimization_problem_t<i_t, f_t> empty_problem(op_problem.get_handle_ptr());
     return third_party_presolve_result_t<i_t, f_t>{status, std::move(empty_problem), {}, {}, {}};
@@
   auto status = convert_papilo_presolve_status_to_third_party_presolve_status(result.status);
   if (result.status == papilo::PresolveStatus::kInfeasible ||
       result.status == papilo::PresolveStatus::kUnbndOrInfeas ||
       result.status == papilo::PresolveStatus::kUnbounded) {
+    papilo_post_solve_storage_.reset();
+    reduced_to_original_map_.clear();
+    original_to_reduced_map_.clear();
     optimization_problem_t<i_t, f_t> empty_problem(op_problem.get_handle_ptr());
     return third_party_presolve_result_t<i_t, f_t>{status, std::move(empty_problem), {}, {}, {}};
   }

Based on learnings: Ensure variables and constraints are accessed from the correct problem context (original vs presolve vs folded vs postsolve); verify index mapping consistency across problem transformations.

Also applies to: 676-681

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp/src/mip_heuristics/presolve/third_party_presolve.cpp` around lines 612 -
624, The early-return branches that construct empty
third_party_presolve_result_t (when ctx.status is INFEASIBLE/UNBNDORINFEAS and
the terminal Papilo return around 676-681) do not clear the instance-level
cached mappings/postsolve payloads (pslp_presolver_, pslp_stgs_, and the object
caches exposed by get_reduced_to_original_map()/get_original_to_reduced_map()),
causing stale mappings on reuse; fix by explicitly resetting/clearing
pslp_presolver_, pslp_stgs_ and any internal mapping/postsolve containers (or
calling the presolver's clear/reset method) before returning the empty
third_party_presolve_result_t so mappings and postsolve state are consistent for
subsequent calls.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] unbounded problem solved at presolve wrongly reported infeasible

1 participant