Skip to content

Add semi continuous variables support#1096

Open
hlinsen wants to merge 23 commits intoNVIDIA:mainfrom
hlinsen:semi-continuous-vars
Open

Add semi continuous variables support#1096
hlinsen wants to merge 23 commits intoNVIDIA:mainfrom
hlinsen:semi-continuous-vars

Conversation

@hlinsen
Copy link
Copy Markdown
Contributor

@hlinsen hlinsen commented Apr 14, 2026

  • Add semi-continuous handling to the MIP solve path through dedicated reformulation before Papilo presolve.
  • Normalize semi-continuous variables with zero lower bound to continuous variables before validation and solve.
  • Treat very large semi-continuous upper bounds as effectively infinite during semi-continuous reformulation.
  • Allow bounds strengthening in semi-continuous reformulation to replace non-finite or loose upper bounds with tighter finite bounds.
  • Keep solve_mip on the MIP entry path, but fall back to LP solve after presolve when no integer variables remain.

Closes: #1059

@hlinsen hlinsen added this to the 26.06 milestone Apr 14, 2026
@hlinsen hlinsen added feature request New feature or request non-breaking Introduces a non-breaking change labels Apr 14, 2026
@hlinsen hlinsen requested review from a team as code owners April 14, 2026 05:26
@copy-pr-bot
Copy link
Copy Markdown

copy-pr-bot bot commented Apr 14, 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.

@hlinsen hlinsen marked this pull request as draft April 14, 2026 05:26
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements support for semi-continuous variables across the CUOPT solver stack, including MPS parser enhancements to recognize semi-continuous bound types, a new variable type enum value, reformulation logic that converts semi-continuous variables into binary variables with linking constraints, bounds propagation to derive finite upper bounds with fallback to a configurable big-M parameter, validation checks, and comprehensive test coverage.

Changes

Cohort / File(s) Summary
Constants & Parameter Registry
cpp/include/cuopt/linear_programming/constants.h, cpp/src/math_optimization/solver_settings.cu
Added CUOPT_SC_BIG_M constant and registered sc_big_m as a float parameter with range [1.0, ∞) and default 1e5.
Solver Settings
cpp/include/cuopt/linear_programming/mip/solver_settings.hpp
Added public sc_big_m member (default 1e5) to mip_solver_settings_t and new detail::mip_solver_settings_accessor friend struct to manage callback clearing.
Variable Type Support
cpp/include/cuopt/linear_programming/optimization_problem_interface.hpp, cpp/include/cuopt/linear_programming/optimization_problem_utils.hpp
Extended var_t enum with SEMI_CONTINUOUS enumerator; updated MPS variable type conversion to map 'S' character to var_t::SEMI_CONTINUOUS.
Problem Categorization
cpp/src/pdlp/optimization_problem.cu, cpp/src/pdlp/solve.cu
Updated problem-type detection and MPS variable mapping to recognize SEMI_CONTINUOUS variables as discrete-like for IP/MIP classification.
MPS Parser Improvements
cpp/libmps_parser/src/mps_parser.hpp, cpp/libmps_parser/src/mps_parser.cpp, cpp/libmps_parser/tests/mps_parser_test.cpp
Renamed SemiContiniousVariable to SemiContinuousVariable (typo fix); added support for 'SC' bound type; implemented actual semi-continuous bound parsing with upper-bound computation; added lower-bounds tracking via lower_bounds_defined_for_var_id; added regression test for three SC dataset files.
Reformulation & Presolve Logic
cpp/src/mip_heuristics/presolve/semi_continuous.cuh, cpp/src/mip_heuristics/presolve/semi_continuous.cu, cpp/src/mip_heuristics/CMakeLists.txt
Introduced reformulate_semi_continuous template function that detects semi-continuous variables, runs GPU bounds presolve for tightening, reformulates variables with non-finite upper bounds using binary variables and linking constraints, applies fallback sc_big_m when needed, and returns success/failure flag plus fallback usage mask.
MIP Solver Integration
cpp/src/mip_heuristics/solve.cu
Added semi-continuous presolve invocation before Papilo; implemented zero lower-bound normalization with warning; disabled warm-starts when SC variables detected; added solution validation to detect fallback big-M usage and return RuntimeError if active; added includes for SC utilities and host-side math.
Validation & Problem Checking
cpp/src/pdlp/utilities/problem_checking.cu
Added conditional validation for SEMI_CONTINUOUS variables requiring: lower bound > 0, upper bound > 0, and lower bound < upper bound.
Remote Solve Support
cpp/src/grpc/client/solve_remote.cpp
Added semi-continuous variable detection; clears MIP callbacks (with warning) when SC variables present and incumbent callbacks provided, disabling incumbent tracking.
Test Data & Test Suite
datasets/mip/sc_*.mps, cpp/tests/mip/semi_continuous_test.cu, cpp/tests/mip/CMakeLists.txt
Added four MPS dataset files (sc_standard.mps, sc_no_ub.mps, sc_lb_zero.mps, sc_inferred_ub.mps) for SC variable testing; added comprehensive test suite with regression tests (MPS parsing and solving) and validation-error tests for invalid bounds.
Miscellaneous
cpp/cuopt_cli.cpp
Removed inline comment "Populate the problem from MPS data model".

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.45% 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 clearly and concisely describes the main feature being added: support for semi-continuous variables in the solver.
Description check ✅ Passed The description is directly related to the changeset, detailing the semi-continuous variable handling implementation and linking to the relevant issue.
Linked Issues check ✅ Passed The PR implements all core requirements from #1059: semi-continuous variable support with finite upper bounds using binary reformulation, bound strengthening for infinite bounds, and user-configurable big-M parameter.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing semi-continuous variable support as specified in the linked issue; no unrelated modifications detected.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


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

Copy link
Copy Markdown

@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: 7

🧹 Nitpick comments (3)
cpp/CMakeLists.txt (1)

292-297: Align OpenSSL discovery with gRPC/protobuf patterns for consistency.

The current fallback logic checks OpenSSL_FOUND/OPENSSL_FOUND variables before retrying discovery, but the identical patterns for gRPC and protobuf (lines 303–310) rely solely on target-based checks. This inconsistency is unnecessary—since FOUND variables can be unreliable for confirming target availability, use target re-checks instead.

Suggested patch
 if(NOT TARGET OpenSSL::SSL)
   find_package(OpenSSL CONFIG QUIET)
-  if(NOT OpenSSL_FOUND AND NOT OPENSSL_FOUND)
-    find_package(OpenSSL REQUIRED)
-  endif()
+endif()
+if(NOT TARGET OpenSSL::SSL)
+  find_package(OpenSSL REQUIRED)
 endif()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp/CMakeLists.txt` around lines 292 - 297, The OpenSSL discovery block
should mirror the gRPC/protobuf pattern by relying on target checks instead of
FOUND variables; replace the current conditional that tests
OpenSSL_FOUND/OPENSSL_FOUND with a second check for the OpenSSL::SSL target.
Concretely, keep the initial if(NOT TARGET OpenSSL::SSL) find_package(OpenSSL
CONFIG QUIET) call and then, instead of testing OpenSSL_FOUND or OPENSSL_FOUND,
re-check if(NOT TARGET OpenSSL::SSL) and only then call find_package(OpenSSL
REQUIRED) so discovery consistently uses target-based checks.
cpp/include/cuopt/linear_programming/mip/solver_settings.hpp (1)

89-89: Appropriate default for Big-M parameter.

The default value of 1e5 for sc_big_m is a reasonable choice for Big-M reformulations. Users can adjust this if their problem has larger variable bounds.

Consider adding a brief inline comment explaining this parameter's purpose, similar to other settings in this file (e.g., node_limit, max_cut_passes).

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

In `@cpp/include/cuopt/linear_programming/mip/solver_settings.hpp` at line 89, Add
a brief inline comment explaining the purpose and guidance for the Big‑M
parameter by annotating the sc_big_m setting: clarify that sc_big_m (default
f_t(1e5)) is the Big‑M constant used in reformulations, why 1e5 is a reasonable
default and that users may increase it for problems with larger variable bounds;
place the comment adjacent to the sc_big_m declaration so it matches style of
other settings like node_limit and max_cut_passes.
cpp/tests/mip/miplib_test.cu (1)

111-136: Add at least one regression where the SC variable is active.

Both new MPS cases optimize to x = 0, so they only cover the inactive branch. The b = 1 linking constraints, positive-interval postsolve path, and fallback-sc_big_m handling are still untested here.

As per coding guidelines, "Add tests for problem transformations: verify correctness of original→transformed→postsolve mappings and index consistency across problem representations".

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

In `@cpp/tests/mip/miplib_test.cu` around lines 111 - 136, The current test vector
in mip_solve::semi_continuous_regressions only includes cases where the
semi-continuous (SC) variable is inactive; add at least one MPS test instance
where the SC variable is active (e.g., "mip/sc_active.mps") and include the
expected objective and non-zero sc_value in the test_instances initializer
(sc_result_t) so the loop exercises the active-branch, the b=1 linking
constraint, the positive-interval postsolve, and fallback sc_big_m handling;
update/verify expectations used by parse_mps, solve_mip, and host_copy in this
test so EXPECT_EQ/EXPECT_NEAR assertions validate the
original→transformed→postsolve mappings and index consistency for that active
instance.
🤖 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/include/cuopt/linear_programming/optimization_problem_interface.hpp`:
- Line 23: The new enum value var_t::SEMI_CONTINUOUS was added but the GRPC
mapping logic still only handles CONTINUOUS/INTEGER and throws in the default
branch; update the mappings in cpp/src/grpc/grpc_problem_mapper.cpp to handle
SEMI_CONTINUOUS everywhere it is converted. Specifically, add cases for
var_t::SEMI_CONTINUOUS in the var_t -> proto enum mapper, the proto enum ->
var_t mapper, and the chunked-transfer/remote-solve mapper that currently throws
on unknown values so that SEMI_CONTINUOUS is correctly translated in all three
places (preserve existing switch/return patterns and cover the default branch
accordingly).

In `@cpp/include/cuopt/linear_programming/optimization_problem_utils.hpp`:
- Around line 261-263: The MPS writer currently treats semi-continuous variables
like continuous ones, causing loss of 'S' on round-trips; update the
bounds-serialization logic in the MPS writer (mps_writer) to check
variable_types[j] == 'S' and emit the 'SC' bound marker (and corresponding bound
lines) for those variables just like the integer branch emits "LI"/"UI"—i.e.,
add a branch alongside the existing integer ('I'/'B') handling so
semi-continuous variables are serialized with 'SC' and preserved when parsing
back (refer to variable_types[j] and the bounds-selection code in the writer).

In `@cpp/libmps_parser/tests/mps_parser_test.cpp`:
- Line 19: Remove the unused include directive for <fstream> in the test file:
delete the line containing `#include` <fstream> (the header added at top of
cpp/libmps_parser/tests/mps_parser_test.cpp) since file operations use
std::filesystem and no symbol from <fstream> is referenced.

In `@cpp/src/mip_heuristics/presolve/sc_reformulation.cu`:
- Around line 147-151: The code assigns big_m into U without validating it;
change the fallback logic in the block handling non-finite U so that before
assigning U = big_m you verify std::isfinite(big_m) && (big_m > L) (where L is
the corresponding lower bound used earlier or orig_u), and only then set U and
mark used_fallback_big_m[idx]=1; otherwise keep U as orig_u (or leave it
non-finite and handle as before) and do not mark the fallback used. Update
references around U, orig_u, big_m, used_fallback_big_m and idx to ensure the
fallback flag is only set when the validated big_m was actually applied.

In `@cpp/src/mip_heuristics/solve.cu`:
- Around line 291-304: The in-place semi-continuous reformulation
(detail::reformulate_semi_continuous) mutates op_problem and adds auxiliary
binaries so op_problem.get_n_variables() no longer equals the user's original
variable count; ensure all MIP callback setup and payloads (GET_SOLUTION /
SET_SOLUTION code paths) use the saved n_orig_before_sc rather than the
reformulated dimension, and trim any solution vectors or callback assignments to
the original prefix of length n_orig_before_sc before invoking user callbacks;
update any callback registration or solution-copy logic to reference
n_orig_before_sc and strip trailing auxiliary variables added by the
reformulation prior to calling user code.
- Around line 291-298: Move creation/starting of the solve timer (the timer_t
instance) to before the semi‑continuous reformulation so the SC phase is counted
against settings.time_limit; specifically, create/start the timer prior to
calling detail::reformulate_semi_continuous(op_problem, settings,
&sc_used_fallback_big_m) (and pass the timer into that routine if it accepts
one) so the time spent in sc_reformulation.cu is budgeted and included in the
reported presolve/solve times; ensure any later references to timer_t (e.g.,
when recording presolve time) still use this same instance so reported timings
include the SC work.

In `@cpp/src/pdlp/utilities/problem_checking.cu`:
- Around line 220-251: The loop in problem_checking.cu indexes var_lb and var_ub
without ensuring bounds were materialized, which can crash when callers set
SEMI_CONTINUOUS types without initializing bounds; before calling
op_problem.get_variable_lower_bounds_host() / get_variable_upper_bounds_host()
or before indexing var_lb[i]/var_ub[i], add a guard that verifies the bounds
vectors are present and sized to match var_types (e.g., check that
op_problem.get_variable_lower_bounds().is_empty() is false or that var_lb.size()
== var_types.size()), and if not, emit a cuopt_expects ValidationError with a
clear message about missing variable bounds for SEMI_CONTINUOUS variables; keep
the existing per-variable SEMI_CONTINUOUS checks (var_types, var_lb, var_ub,
cuopt_expects) but only after this presence/size validation.

---

Nitpick comments:
In `@cpp/CMakeLists.txt`:
- Around line 292-297: The OpenSSL discovery block should mirror the
gRPC/protobuf pattern by relying on target checks instead of FOUND variables;
replace the current conditional that tests OpenSSL_FOUND/OPENSSL_FOUND with a
second check for the OpenSSL::SSL target. Concretely, keep the initial if(NOT
TARGET OpenSSL::SSL) find_package(OpenSSL CONFIG QUIET) call and then, instead
of testing OpenSSL_FOUND or OPENSSL_FOUND, re-check if(NOT TARGET OpenSSL::SSL)
and only then call find_package(OpenSSL REQUIRED) so discovery consistently uses
target-based checks.

In `@cpp/include/cuopt/linear_programming/mip/solver_settings.hpp`:
- Line 89: Add a brief inline comment explaining the purpose and guidance for
the Big‑M parameter by annotating the sc_big_m setting: clarify that sc_big_m
(default f_t(1e5)) is the Big‑M constant used in reformulations, why 1e5 is a
reasonable default and that users may increase it for problems with larger
variable bounds; place the comment adjacent to the sc_big_m declaration so it
matches style of other settings like node_limit and max_cut_passes.

In `@cpp/tests/mip/miplib_test.cu`:
- Around line 111-136: The current test vector in
mip_solve::semi_continuous_regressions only includes cases where the
semi-continuous (SC) variable is inactive; add at least one MPS test instance
where the SC variable is active (e.g., "mip/sc_active.mps") and include the
expected objective and non-zero sc_value in the test_instances initializer
(sc_result_t) so the loop exercises the active-branch, the b=1 linking
constraint, the positive-interval postsolve, and fallback sc_big_m handling;
update/verify expectations used by parse_mps, solve_mip, and host_copy in this
test so EXPECT_EQ/EXPECT_NEAR assertions validate the
original→transformed→postsolve mappings and index consistency for that active
instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1383efd9-9c0c-427d-a6d6-8e526702b927

📥 Commits

Reviewing files that changed from the base of the PR and between 24fdb60 and f7a32fe.

📒 Files selected for processing (21)
  • cpp/CMakeLists.txt
  • cpp/cuopt_cli.cpp
  • cpp/include/cuopt/linear_programming/constants.h
  • cpp/include/cuopt/linear_programming/mip/solver_settings.hpp
  • cpp/include/cuopt/linear_programming/optimization_problem_interface.hpp
  • cpp/include/cuopt/linear_programming/optimization_problem_utils.hpp
  • cpp/libmps_parser/src/mps_parser.cpp
  • cpp/libmps_parser/src/mps_parser.hpp
  • cpp/libmps_parser/tests/mps_parser_test.cpp
  • cpp/src/math_optimization/solver_settings.cu
  • cpp/src/mip_heuristics/CMakeLists.txt
  • cpp/src/mip_heuristics/presolve/sc_reformulation.cu
  • cpp/src/mip_heuristics/presolve/sc_reformulation.cuh
  • cpp/src/mip_heuristics/solve.cu
  • cpp/src/pdlp/optimization_problem.cu
  • cpp/src/pdlp/solve.cu
  • cpp/src/pdlp/utilities/problem_checking.cu
  • cpp/tests/mip/miplib_test.cu
  • datasets/mip/sc_lb_zero.mps
  • datasets/mip/sc_no_ub.mps
  • datasets/mip/sc_standard.mps
💤 Files with no reviewable changes (1)
  • cpp/cuopt_cli.cpp

Comment on lines +261 to +263
if (val == 'I' || val == 'B') return var_t::INTEGER;
if (val == 'S') return var_t::SEMI_CONTINUOUS;
return var_t::CONTINUOUS;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if MPS writer handles 'S' type variables for semi-continuous bounds
rg -n "'S'" cpp/libmps_parser/src/mps_writer.cpp

# Check how variable types are handled in the writer
rg -n -C3 "variable_types" cpp/libmps_parser/src/mps_writer.cpp | head -60

Repository: NVIDIA/cuopt

Length of output: 3159


🏁 Script executed:

#!/bin/bash
# Verify the exact file and lines mentioned in the review
wc -l cpp/include/cuopt/linear_programming/optimization_problem_utils.hpp
sed -n '255,270p' cpp/include/cuopt/linear_programming/optimization_problem_utils.hpp

# Check for any 'SC' bounds handling in MPS writer
rg -n "'SC'" cpp/libmps_parser/src/mps_writer.cpp

# Check for semi-continuous handling anywhere in the writer
rg -n -i "semi" cpp/libmps_parser/src/mps_writer.cpp

# Get full context of bounds writing section
sed -n '335,370p' cpp/libmps_parser/src/mps_writer.cpp

Repository: NVIDIA/cuopt

Length of output: 2640


🏁 Script executed:

#!/bin/bash
# Search for any comments about semi-continuous in MPS writer
rg -n -i "semi\|sc bounds\|bound.*type" cpp/libmps_parser/src/mps_writer.cpp | head -20

# Check the MPS data model to understand how SC is meant to be handled
rg -n "'S'" cpp/libmps_parser/src/ -A2 -B2 | head -40

# Look at comments/docs about MPS writer scope
head -50 cpp/libmps_parser/src/mps_writer.cpp

Repository: NVIDIA/cuopt

Length of output: 1905


Code at lines 261-263 is correct; MPS writer lacks semi-continuous serialization.

The lambda correctly converts the 'S' character to var_t::SEMI_CONTINUOUS, maintaining consistency with the parser's handling and mirroring populate_from_mps_data_model logic. However, the MPS writer does not serialize semi-continuous variables: it only distinguishes integer variables ('I') for bound type selection ("LI"/"UI" vs "LO"/"UP"), treating semi-continuous variables identically to continuous ones. This creates an inconsistency where variables with type 'S' parse correctly but lose their semi-continuous classification on round-trip through the writer. The MPS format supports the 'SC' bound marker for semi-continuous bounds, which should be implemented in cpp/libmps_parser/src/mps_writer.cpp to properly serialize variables with variable_types[j] == 'S'.

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

In `@cpp/include/cuopt/linear_programming/optimization_problem_utils.hpp` around
lines 261 - 263, The MPS writer currently treats semi-continuous variables like
continuous ones, causing loss of 'S' on round-trips; update the
bounds-serialization logic in the MPS writer (mps_writer) to check
variable_types[j] == 'S' and emit the 'SC' bound marker (and corresponding bound
lines) for those variables just like the integer branch emits "LI"/"UI"—i.e.,
add a branch alongside the existing integer ('I'/'B') handling so
semi-continuous variables are serialized with 'SC' and preserved when parsing
back (refer to variable_types[j] and the bounds-selection code in the writer).

Comment thread cpp/libmps_parser/tests/mps_parser_test.cpp
Comment on lines +147 to +151
if (!std::isfinite(U)) { U = orig_u; }
if (!std::isfinite(U)) {
U = big_m;
if (used_fallback_big_m != nullptr) { (*used_fallback_big_m)[idx] = uint8_t{1}; }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Validate fallback sc_big_m before using it as U.

When propagation cannot derive a finite bound, this path injects settings.sc_big_m directly into the reformulation. If that value is non-finite or <= L, the new constraint gets an invalid or too-tight U, which can silently make the b = 1 branch impossible or return a truncated-model result without ever tripping the post-solve x ≈ M check on Line 580 of cpp/src/mip_heuristics/solve.cu.

Proposed validation
+#include <cuopt/error.hpp>
...
     if (!std::isfinite(U)) {
+      cuopt_expects(std::isfinite(big_m) && big_m > L,
+                    cuopt::error_type_t::ValidationError,
+                    "sc_big_m must be finite and strictly greater than the semi-continuous lower bound when used as a fallback upper bound");
       U = big_m;
       if (used_fallback_big_m != nullptr) { (*used_fallback_big_m)[idx] = uint8_t{1}; }
     }
As per coding guidelines, "Validate correct initialization of variable bounds, constraint coefficients, and algorithm state before solving; ensure reset when transitioning between algorithm phases (presolve, simplex, diving, crossover)".
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!std::isfinite(U)) { U = orig_u; }
if (!std::isfinite(U)) {
U = big_m;
if (used_fallback_big_m != nullptr) { (*used_fallback_big_m)[idx] = uint8_t{1}; }
}
if (!std::isfinite(U)) { U = orig_u; }
if (!std::isfinite(U)) {
cuopt_expects(std::isfinite(big_m) && big_m > L,
cuopt::error_type_t::ValidationError,
"sc_big_m must be finite and strictly greater than the semi-continuous lower bound when used as a fallback upper bound");
U = big_m;
if (used_fallback_big_m != nullptr) { (*used_fallback_big_m)[idx] = uint8_t{1}; }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp/src/mip_heuristics/presolve/sc_reformulation.cu` around lines 147 - 151,
The code assigns big_m into U without validating it; change the fallback logic
in the block handling non-finite U so that before assigning U = big_m you verify
std::isfinite(big_m) && (big_m > L) (where L is the corresponding lower bound
used earlier or orig_u), and only then set U and mark
used_fallback_big_m[idx]=1; otherwise keep U as orig_u (or leave it non-finite
and handle as before) and do not mark the fallback used. Update references
around U, orig_u, big_m, used_fallback_big_m and idx to ensure the fallback flag
is only set when the validated big_m was actually applied.

Comment on lines +291 to +304
// Reformulate semi-continuous variables (x = 0 OR L <= x <= U) before Papilo presolve.
// Uses GPU bounds propagation to derive tight upper bounds for SC vars with infinite UB.
// Track n_orig so that auxiliary binary variables added by reformulation can be stripped
// from the solution before returning it to the caller.
const i_t n_orig_before_sc = op_problem.get_n_variables();
std::vector<uint8_t> sc_used_fallback_big_m;
const bool had_sc =
detail::reformulate_semi_continuous(op_problem, settings, &sc_used_fallback_big_m);
if (had_sc && !settings.initial_solutions.empty()) {
CUOPT_LOG_WARN(
"Ignoring %zu user initial solution(s): semi-continuous warm starts are not supported yet",
settings.initial_solutions.size());
settings.initial_solutions.clear();
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 14, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep MIP callbacks in the original variable space.

After this in-place reformulation, op_problem.get_n_variables() includes the auxiliary SC binaries. The final return path trims them on Lines 566-570, but callback setup and callback payloads still use the reformulated dimension, so GET_SOLUTION / SET_SOLUTION clients will now see extra trailing variables that do not exist in the user model. Please use n_orig_before_sc for callback setup and trim callback assignments back to the original prefix before invoking user code.

As per coding guidelines, "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".

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

In `@cpp/src/mip_heuristics/solve.cu` around lines 291 - 304, The in-place
semi-continuous reformulation (detail::reformulate_semi_continuous) mutates
op_problem and adds auxiliary binaries so op_problem.get_n_variables() no longer
equals the user's original variable count; ensure all MIP callback setup and
payloads (GET_SOLUTION / SET_SOLUTION code paths) use the saved n_orig_before_sc
rather than the reformulated dimension, and trim any solution vectors or
callback assignments to the original prefix of length n_orig_before_sc before
invoking user callbacks; update any callback registration or solution-copy logic
to reference n_orig_before_sc and strip trailing auxiliary variables added by
the reformulation prior to calling user code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah I see. Your solution is potentially too long (because of the addition of binary variables). Can we just add another uncrush step in the callback?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@chris-maes Yes, that's a clean approach. Since the SC reformulation is applied before Papilo presolve, the layered "uncrush" chain is:

papilo-presolved space
  → (Papilo uncrush) → SC-reformulated space (includes auxiliary binaries)
  → (SC trim / "uncrush") → original user space [0 … n_orig_before_sc)

The SC uncrush is trivial — just take the first n_orig_before_sc elements — so you could thread n_orig_before_sc into each callback invocation site and resize/trim there rather than blanket-clearing callbacks. The two places that need updating:

  1. Callback setup in solve_mip (~line 332): use n_orig_before_sc instead of op_problem.get_n_variables() so the callback is dimensioned for the user's variables.

  2. run_mip's Papilo-phase incumbent callback — after presolver_ptr->uncrush_primal_solution(assignment, user_assignment) produces an SC-dimensional vector, add:

    if (n_orig_before_sc > 0 && user_assignment.size() > static_cast<size_t>(n_orig_before_sc))
      user_assignment.resize(n_orig_before_sc);

    before calling invoke_solution_callbacks.

  3. solve_mip's early FJ early_fj_callback — the assignment there is already in "user space" post early-FJ (which ran on the SC-reformulated op_problem), so the same trim applies before invoke_solution_callbacks.

This would allow you to remove the clear_mip_callbacks block and instead keep callbacks functional while giving users only their original variables. Would you like me to draft the full diff for this change?


🧠 Learnings used
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 899
File: cpp/src/mip_heuristics/solve.cu:169-193
Timestamp: 2026-02-24T14:37:06.700Z
Learning: In cuOpt's MIP solver, the sign of objective_scaling_factor encodes the problem direction: >= 0 indicates minimization; < 0 indicates maximization (internally converted to minimization by negating the objective). Therefore, during code reviews, treat the sign of objective_scaling_factor as the source of truth for problem direction and use it to set appropriate 'no bound' values for callbacks accordingly.

Comment on lines +291 to +298
// Reformulate semi-continuous variables (x = 0 OR L <= x <= U) before Papilo presolve.
// Uses GPU bounds propagation to derive tight upper bounds for SC vars with infinite UB.
// Track n_orig so that auxiliary binary variables added by reformulation can be stripped
// from the solution before returning it to the caller.
const i_t n_orig_before_sc = op_problem.get_n_variables();
std::vector<uint8_t> sc_used_fallback_big_m;
const bool had_sc =
detail::reformulate_semi_continuous(op_problem, settings, &sc_used_fallback_big_m);
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 14, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Start the solve timer before SC reformulation.

timer_t is only created on Line 325, but this new phase can already spend up to five seconds in cpp/src/mip_heuristics/presolve/sc_reformulation.cu (bp_settings.time_limit = 5.0). A small settings.time_limit can now be exceeded before the solver starts, and the reported presolve time will miss that work. Please budget the SC reformulation against the user time limit.

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

In `@cpp/src/mip_heuristics/solve.cu` around lines 291 - 298, Move
creation/starting of the solve timer (the timer_t instance) to before the
semi‑continuous reformulation so the SC phase is counted against
settings.time_limit; specifically, create/start the timer prior to calling
detail::reformulate_semi_continuous(op_problem, settings,
&sc_used_fallback_big_m) (and pass the timer into that routine if it accepts
one) so the time spent in sc_reformulation.cu is budgeted and included in the
reported presolve/solve times; ensure any later references to timer_t (e.g.,
when recording presolve time) still use this same instance so reported timings
include the SC work.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree, best to start solver timer before all of this. Also, we should use the deterministic CPU bound strengthing and give it only the constraints the variable appears in.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

Thanks for the feedback, @chris-maes. Let me look at the current bound-strengthening implementation in semi_continuous.cu and any available CPU-side utilities before summarizing what changes are needed.


🏁 Script executed:

#!/bin/bash
# Look at semi_continuous.cu in full
fd "semi_continuous.cu" --exec cat {}

echo "---BOUND_PRESOLVE---"
# Find bound_presolve_t usage
rg -n "bound_presolve" --type=cu -C5

echo "---CPU_BOUND_PROP---"
# Look for CPU-side bound propagation utilities
rg -rn "cpu.*bound|bound.*cpu|trivial_presolve|propagat" --type=cu --type=cpp -C3 -g '!*test*'

echo "---TIMER_USAGE---"
# How timer_t is used in solve.cu
rg -n "timer" cpp/src/mip_heuristics/solve.cu

Length of output: 13044


🏁 Script executed:

#!/bin/bash
# Find and print bounds_presolve.cuh
fd "bounds_presolve.cuh" --exec cat {}

echo "===TIMER_T==="
fd "timer" -e hpp -e cuh --exec cat {}

echo "===TRIVIAL_PRESOLVE==="
fd "trivial_presolve" -e cu -e cuh --exec cat {}

echo "===CPU_PROPAGATE==="
# Look for CPU-side propagation/bound-strengthening utilities
rg -n "propagat\|cpu.*bound\|bound.*propagat" -g "*.cu" -g "*.cuh" -g "*.hpp" -g "*.cpp" -C3

Length of output: 42531


@chris-maes, fully agreed on both points. Here's a concrete description of the two changes needed:


1. Budget the SC phase against the user time limit

In solve.cu, move the timer_t creation (currently line 364) to before the reformulate_semi_continuous call, then pass the budget into it:

// solve.cu — move timer creation up, before SC reformulation
auto timer = timer_t(time_limit);   // ← moved from line 364

normalize_zero_lb_semi_continuous(op_problem);
// ...
const bool had_sc =
  detail::reformulate_semi_continuous(op_problem, settings, &sc_used_fallback_big_m, timer);

In semi_continuous.cuh / semi_continuous.cu, update the signature to accept the timer:

template <typename i_t, typename f_t>
bool reformulate_semi_continuous(optimization_problem_t<i_t, f_t>& op_problem,
                                 const mip_solver_settings_t<i_t, f_t>& settings,
                                 std::vector<uint8_t>* used_fallback_big_m = nullptr,
                                 std::optional<timer_t> timer = std::nullopt);

Then replace the hard-coded 5.0 with the clamped remaining budget:

// semi_continuous.cu — inside reformulate_semi_continuous
bp_settings.time_limit = timer.has_value()
                           ? timer->clamp_remaining_time(5.0)
                           : 5.0;

2. Replace GPU bound propagation with deterministic CPU interval arithmetic, scoped to relevant constraints

Instead of building a full relaxed optimization_problem_t copy, constructing problem_t, and running the GPU bound_presolve_t, replace the entire step 3 block with a CPU-only pass using standard interval arithmetic. For each SC variable k that needs a finite UB, iterate only over constraints in which k appears (already available as host CSR offsets/indices/values from the get_constraint_matrix_*_host() calls done for step 4):

// Replace the GPU bounds-propagation block with a targeted CPU pass.
// For each constraint row i containing SC variable k with coefficient a_{ik},
// derive the implied upper bound on x_k:
//   if a_{ik} > 0 and cub[i] < +inf:
//       implied_ub = (cub[i] - sum_{j≠k} a_{ij} * (a_{ij}>0 ? lb[j] : ub[j])) / a_{ik}
// Take the min over all such constraints.

auto tight_ub = var_ub;  // fallback: normalized original UBs

if (op_relaxed.get_n_constraints() > 0) {
  // Fetch constraint data (already needed for step 4 below, so hoist those calls)
  const auto A_vals_h = op_problem.get_constraint_matrix_values_host();
  const auto A_idx_h  = op_problem.get_constraint_matrix_indices_host();
  const auto A_off_h  = op_problem.get_constraint_matrix_offsets_host();
  const auto cub_h    = op_problem.get_constraint_upper_bounds_host();
  const auto var_lb_h = op_problem.get_variable_lower_bounds_host();
  const auto var_ub_h = var_ub;  // (already normalized)

  // Build a CSC (column-to-row) index for SC variables that need tightening.
  // Since n_sc is typically small, a simple O(nnz) scan is fine.
  for (i_t s = 0; s < n_sc; ++s) {
    const i_t k = sc_indices[s];
    if (std::isfinite(tight_ub[k])) { continue; }  // already finite, skip

    f_t best_ub = std::numeric_limits<f_t>::infinity();
    const i_t n_cons = static_cast<i_t>(A_off_h.size()) - 1;

    for (i_t row = 0; row < n_cons; ++row) {
      f_t a_ik = f_t(0);
      for (i_t p = A_off_h[row]; p < A_off_h[row + 1]; ++p) {
        if (A_idx_h[p] == k) { a_ik = A_vals_h[p]; break; }
      }
      if (a_ik <= f_t(0) || !std::isfinite(cub_h[row])) { continue; }

      // Sum contributions from other variables
      f_t residual = cub_h[row];
      for (i_t p = A_off_h[row]; p < A_off_h[row + 1]; ++p) {
        const i_t j = A_idx_h[p];
        if (j == k) { continue; }
        const f_t a_ij = A_vals_h[p];
        const f_t bnd  = (a_ij > f_t(0)) ? var_lb_h[j] : var_ub_h[j];
        if (!std::isfinite(bnd)) { residual = std::numeric_limits<f_t>::infinity(); break; }
        residual -= a_ij * bnd;
      }
      if (std::isfinite(residual)) {
        best_ub = std::min(best_ub, residual / a_ik);
      }
    }
    tight_ub[k] = best_ub;
  }
}

This eliminates the op_relaxed copy, the problem_t / mip_solver_context_t construction, and the GPU bound_presolve_t entirely. The scan is O(n_sc × nnz_relevant), fully deterministic, runs on CPU, and only touches constraints where the SC variable actually appears.

Note: If the outer loop over rows proves too slow for dense problems, you can pre-build an explicit CSC index for just the SC variables in one O(nnz) pass before the per-variable loops.


✏️ Learnings added
Learnt from: chris-maes
Repo: NVIDIA/cuopt PR: 1096
File: cpp/src/mip_heuristics/solve.cu:330-337
Timestamp: 2026-04-17T00:22:53.670Z
Learning: In cuOpt's semi-continuous reformulation (cpp/src/mip_heuristics/presolve/semi_continuous.cu), the project maintainer (chris-maes) prefers deterministic CPU-side interval-arithmetic bound strengthening scoped to only the constraints each SC variable appears in, rather than the GPU bound_presolve_t approach on a full relaxed problem copy. The SC reformulation timer should be budgeted against the user time_limit (timer_t created before calling reformulate_semi_continuous and passed in), using timer.clamp_remaining_time() instead of a hard-coded 5.0s limit.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 899
File: cpp/src/mip_heuristics/solve.cu:169-193
Timestamp: 2026-02-24T14:37:06.700Z
Learning: In cuOpt's MIP solver, the sign of objective_scaling_factor encodes the problem direction: >= 0 indicates minimization; < 0 indicates maximization (internally converted to minimization by negating the objective). Therefore, during code reviews, treat the sign of objective_scaling_factor as the source of truth for problem direction and use it to set appropriate 'no bound' values for callbacks accordingly.

Comment on lines +220 to +251
if (!op_problem.get_variable_types().is_empty()) {
cuopt_expects(
op_problem.get_variable_types().size() == op_problem.get_objective_coefficients().size(),
error_type_t::ValidationError,
"Sizes for vectors related to the variables are not the same. The objective "
"vector has size %zu and the variable types vector has size %zu.",
op_problem.get_objective_coefficients().size(),
op_problem.get_variable_types().size());

auto var_types = op_problem.get_variable_types_host();
auto var_lb = op_problem.get_variable_lower_bounds_host();
auto var_ub = op_problem.get_variable_upper_bounds_host();
for (i_t i = 0; i < static_cast<i_t>(var_types.size()); ++i) {
if (var_types[i] != var_t::SEMI_CONTINUOUS) { continue; }
cuopt_expects(var_lb[i] > f_t(0),
error_type_t::ValidationError,
"Semi-continuous variable must have a strictly positive lower bound, but has "
"lower bound %g.",
static_cast<double>(var_lb[i]));
cuopt_expects(var_ub[i] > f_t(0),
error_type_t::ValidationError,
"Semi-continuous variable must have a strictly positive upper bound, but has "
"upper bound %g.",
static_cast<double>(var_ub[i]));
cuopt_expects(var_lb[i] < var_ub[i],
error_type_t::ValidationError,
"Semi-continuous variable must satisfy lower bound < upper bound, but has "
"bounds [%g, %g].",
static_cast<double>(var_lb[i]),
static_cast<double>(var_ub[i]));
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate SC bounds are present before indexing the host copies.

Direct API callers can set SEMI_CONTINUOUS types without materializing bounds first. In that case, Lines 234-249 read var_lb[i] and var_ub[i] before a clean validation error is emitted.

Suggested guard
   if (!op_problem.get_variable_types().is_empty()) {
     cuopt_expects(
       op_problem.get_variable_types().size() == op_problem.get_objective_coefficients().size(),
       error_type_t::ValidationError,
       "Sizes for vectors related to the variables are not the same. The objective "
       "vector has size %zu and the variable types vector has size %zu.",
       op_problem.get_objective_coefficients().size(),
       op_problem.get_variable_types().size());

     auto var_types = op_problem.get_variable_types_host();
+    bool has_semi_continuous = false;
+    for (const auto type : var_types) {
+      if (type == var_t::SEMI_CONTINUOUS) {
+        has_semi_continuous = true;
+        break;
+      }
+    }
+    if (has_semi_continuous) {
+      cuopt_expects(op_problem.get_variable_lower_bounds().size() == var_types.size() &&
+                      op_problem.get_variable_upper_bounds().size() == var_types.size(),
+                    error_type_t::ValidationError,
+                    "Semi-continuous variables require explicit lower and upper bounds.");
+    }

     auto var_types = op_problem.get_variable_types_host();
     auto var_lb    = op_problem.get_variable_lower_bounds_host();
     auto var_ub    = op_problem.get_variable_upper_bounds_host();

As per coding guidelines "Validate correct initialization of variable bounds, constraint coefficients, and algorithm state before solving; ensure reset when transitioning between algorithm phases (presolve, simplex, diving, crossover)."

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

In `@cpp/src/pdlp/utilities/problem_checking.cu` around lines 220 - 251, The loop
in problem_checking.cu indexes var_lb and var_ub without ensuring bounds were
materialized, which can crash when callers set SEMI_CONTINUOUS types without
initializing bounds; before calling op_problem.get_variable_lower_bounds_host()
/ get_variable_upper_bounds_host() or before indexing var_lb[i]/var_ub[i], add a
guard that verifies the bounds vectors are present and sized to match var_types
(e.g., check that op_problem.get_variable_lower_bounds().is_empty() is false or
that var_lb.size() == var_types.size()), and if not, emit a cuopt_expects
ValidationError with a clear message about missing variable bounds for
SEMI_CONTINUOUS variables; keep the existing per-variable SEMI_CONTINUOUS checks
(var_types, var_lb, var_ub, cuopt_expects) but only after this presence/size
validation.

@hlinsen hlinsen force-pushed the semi-continuous-vars branch from 2a5da0c to 4ca42b1 Compare April 16, 2026 00:46
@hlinsen hlinsen requested review from chris-maes and mlubin and removed request for Bubullzz and nguidotti April 16, 2026 00:47
@hlinsen hlinsen marked this pull request as ready for review April 16, 2026 00:48
Copy link
Copy Markdown

@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

♻️ Duplicate comments (2)
cpp/src/mip_heuristics/solve.cu (1)

318-321: ⚠️ Potential issue | 🟠 Major

Keep callbacks in the original variable space.

n_orig_before_sc is saved here, but callback setup and callback payloads still use the reformulated dimension later in this file and in run_mip(). GET_SOLUTION / SET_SOLUTION clients will still see the auxiliary SC binaries unless those paths are trimmed back to the original prefix.

As per coding guidelines, "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".

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

In `@cpp/src/mip_heuristics/solve.cu` around lines 318 - 321, The code saves the
original variable count in n_orig_before_sc but still builds callbacks and
payloads using the reformulated/problem-after-SC dimension, causing
GET_SOLUTION/SET_SOLUTION and run_mip callbacks to expose auxiliary
semi‑continuous binaries; update the callback setup and all places that
construct or consume solution vectors to map indices back to the original space
after detail::reformulate_semi_continuous (using n_orig_before_sc and
sc_used_fallback_big_m) so only the first n_orig_before_sc variables (and any
mapped originals) are sent/received, trimming or translating SC auxiliary
indices into the original variable indices in GET_SOLUTION/SET_SOLUTION handlers
and in run_mip callback payload construction.
cpp/src/mip_heuristics/presolve/semi_continuous.cu (1)

102-110: ⚠️ Potential issue | 🟠 Major

Honor the caller’s time limit during SC bound strengthening.

bp_settings.time_limit = 5.0 lets this phase run for up to five seconds even when settings.time_limit is smaller, so SC reformulation can consume the solve budget before the main solve path starts. Please clamp this to the remaining user budget instead of a fixed cap.

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

In `@cpp/src/mip_heuristics/presolve/semi_continuous.cu` around lines 102 - 110,
The hardcoded bp_settings.time_limit = 5.0 lets SC bound strengthening ignore
the caller's remaining time; change it to clamp against the user's remaining
budget (e.g., compute a remaining_budget value from the solver settings or
elapsed time and set bp_settings.time_limit = std::max(0.0, std::min(5.0,
remaining_budget))). Update the code around bound_presolve_t<i_t, f_t> bps(...)
before bps.solve(temp_pb) so the bp_settings.time_limit uses that clamped
remaining budget (ensure you handle absence of remaining_budget by falling back
to settings.time_limit and keep the value non-negative).
🧹 Nitpick comments (2)
cpp/CMakeLists.txt (1)

292-297: Remove duplicated OpenSSL discovery logic.

This block is duplicated later at Lines 304-309 with the same conditions/body. Keeping both increases drift risk; keep one shared check before gRPC/Protobuf resolution.

♻️ Suggested cleanup
 if(NOT TARGET OpenSSL::SSL)
   find_package(OpenSSL CONFIG QUIET)
   if(NOT OpenSSL_FOUND AND NOT OPENSSL_FOUND)
     find_package(OpenSSL REQUIRED)
   endif()
 endif()
 
 # gRPC is required for this branch - it provides remote execution features
 # gRPC can come from either:
 # - an installed CMake package (gRPCConfig.cmake), or
 # - an in-tree build (e.g. python/libcuopt uses FetchContent(grpc), which defines gRPC::grpc++).
-
-if(NOT TARGET OpenSSL::SSL)
-  find_package(OpenSSL CONFIG QUIET)
-  if(NOT OpenSSL_FOUND AND NOT OPENSSL_FOUND)
-    find_package(OpenSSL REQUIRED)
-  endif()
-endif()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp/CMakeLists.txt` around lines 292 - 297, Remove the duplicated OpenSSL
discovery block: keep a single check that tests if(NOT TARGET OpenSSL::SSL) and
then calls find_package(OpenSSL CONFIG QUIET) with the fallback
find_package(OpenSSL REQUIRED) if not found; delete the later repeated block and
ensure the remaining block appears once before gRPC/Protobuf resolution so
OpenSSL::SSL is discovered centrally for subsequent targets.
cpp/tests/mip/semi_continuous_test.cu (1)

65-118: Add a regression for the fallback-sc_big_m guard.

These cases cover valid SC models and invalid bounds, but they never assert the new runtime path where an infinite-UB SC variable remains active at fallback sc_big_m. A targeted case here would protect cpp/src/mip_heuristics/solve.cu:595-611 from silently regressing.

As per coding guidelines, "**/*test*.{cpp,cu,py}: Write tests validating numerical correctness of optimization results (not just 'runs without error'); test degenerate cases (infeasible, unbounded, empty, singleton problems)".

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

In `@cpp/tests/mip/semi_continuous_test.cu` around lines 65 - 118, Add a targeted
unit test that exercises the fallback sc_big_m path for semi‑continuous
variables: create a test (e.g., TEST(mip_solve,
semi_continuous_fallback_sc_big_m)) that builds a problem with an
infinite/unbounded upper bound for the SC variable using make_sc_problem (or
directly construct the problem), call solve_mip(problem, settings), assert
solver returns a valid termination status (Optimal or appropriate status) and
validate numerical correctness of the solution/objective (not just no-error),
and additionally assert that the fallback big‑M path was taken indirectly by
checking the SC variable value is consistent with treating UB as very large
(compare host_solution[0] against expected value within tolerance) so
cpp/src/mip_heuristics/solve.cu's sc_big_m guard is exercised; reuse existing
settings/time_limit and error-checking patterns from semi_continuous_regressions
and semi_continuous_invalid_bounds_rejected.
🤖 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/semi_continuous.cu`:
- Around line 183-190: The code sets U = big_m when std::isfinite(U) is false
but does not validate big_m (settings.sc_big_m) against finiteness or L, which
can make the reformulation infeasible; update the logic around U (and the
fallback big_m) to first check that settings.sc_big_m is finite and greater than
or equal to L before assigning U = big_m, otherwise reject/clamp it (e.g., leave
U as +inf or set U = max(L, min(big_m, some_safe_max))) and avoid writing -U
into the linking row if invalid; also set/clear used_fallback_big_m[idx] only
when a valid fallback was applied so callers know whether a safe value was used.

In `@cpp/src/mip_heuristics/solve.cu`:
- Around line 322-327: The code currently clears settings.initial_solutions
after check_initial_solution_representation() runs, which allows invalid SC warm
starts to be validated and potentially cause failures; before calling
check_initial_solution_representation() ensure semi-continuous (had_sc) models
do not run validation by either clearing settings.initial_solutions when had_sc
is true (as done in the CUOPT_LOG_WARN branch) prior to any validation, or
adding a guard inside check_initial_solution_representation() to skip validation
when had_sc is true; update the logic around had_sc, settings.initial_solutions,
and the check_initial_solution_representation() call so SC warm starts are
dropped before validation and keep the CUOPT_LOG_WARN message referencing
settings.initial_solutions.size().

---

Duplicate comments:
In `@cpp/src/mip_heuristics/presolve/semi_continuous.cu`:
- Around line 102-110: The hardcoded bp_settings.time_limit = 5.0 lets SC bound
strengthening ignore the caller's remaining time; change it to clamp against the
user's remaining budget (e.g., compute a remaining_budget value from the solver
settings or elapsed time and set bp_settings.time_limit = std::max(0.0,
std::min(5.0, remaining_budget))). Update the code around bound_presolve_t<i_t,
f_t> bps(...) before bps.solve(temp_pb) so the bp_settings.time_limit uses that
clamped remaining budget (ensure you handle absence of remaining_budget by
falling back to settings.time_limit and keep the value non-negative).

In `@cpp/src/mip_heuristics/solve.cu`:
- Around line 318-321: The code saves the original variable count in
n_orig_before_sc but still builds callbacks and payloads using the
reformulated/problem-after-SC dimension, causing GET_SOLUTION/SET_SOLUTION and
run_mip callbacks to expose auxiliary semi‑continuous binaries; update the
callback setup and all places that construct or consume solution vectors to map
indices back to the original space after detail::reformulate_semi_continuous
(using n_orig_before_sc and sc_used_fallback_big_m) so only the first
n_orig_before_sc variables (and any mapped originals) are sent/received,
trimming or translating SC auxiliary indices into the original variable indices
in GET_SOLUTION/SET_SOLUTION handlers and in run_mip callback payload
construction.

---

Nitpick comments:
In `@cpp/CMakeLists.txt`:
- Around line 292-297: Remove the duplicated OpenSSL discovery block: keep a
single check that tests if(NOT TARGET OpenSSL::SSL) and then calls
find_package(OpenSSL CONFIG QUIET) with the fallback find_package(OpenSSL
REQUIRED) if not found; delete the later repeated block and ensure the remaining
block appears once before gRPC/Protobuf resolution so OpenSSL::SSL is discovered
centrally for subsequent targets.

In `@cpp/tests/mip/semi_continuous_test.cu`:
- Around line 65-118: Add a targeted unit test that exercises the fallback
sc_big_m path for semi‑continuous variables: create a test (e.g.,
TEST(mip_solve, semi_continuous_fallback_sc_big_m)) that builds a problem with
an infinite/unbounded upper bound for the SC variable using make_sc_problem (or
directly construct the problem), call solve_mip(problem, settings), assert
solver returns a valid termination status (Optimal or appropriate status) and
validate numerical correctness of the solution/objective (not just no-error),
and additionally assert that the fallback big‑M path was taken indirectly by
checking the SC variable value is consistent with treating UB as very large
(compare host_solution[0] against expected value within tolerance) so
cpp/src/mip_heuristics/solve.cu's sc_big_m guard is exercised; reuse existing
settings/time_limit and error-checking patterns from semi_continuous_regressions
and semi_continuous_invalid_bounds_rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 91215217-c7a2-4ff5-bed8-e8c9965e190d

📥 Commits

Reviewing files that changed from the base of the PR and between f7a32fe and 4ca42b1.

📒 Files selected for processing (8)
  • cpp/CMakeLists.txt
  • cpp/src/mip_heuristics/CMakeLists.txt
  • cpp/src/mip_heuristics/presolve/semi_continuous.cu
  • cpp/src/mip_heuristics/presolve/semi_continuous.cuh
  • cpp/src/mip_heuristics/solve.cu
  • cpp/tests/mip/CMakeLists.txt
  • cpp/tests/mip/semi_continuous_test.cu
  • datasets/mip/sc_inferred_ub.mps
✅ Files skipped from review due to trivial changes (1)
  • datasets/mip/sc_inferred_ub.mps
🚧 Files skipped from review as they are similar to previous changes (1)
  • cpp/src/mip_heuristics/CMakeLists.txt

Comment thread cpp/src/mip_heuristics/presolve/semi_continuous.cu
Comment thread cpp/src/mip_heuristics/solve.cu Outdated
Copy link
Copy Markdown

@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.

🧹 Nitpick comments (1)
cpp/src/mip_heuristics/presolve/semi_continuous.cu (1)

158-167: Consider using INFO level instead of WARN for the "already contains 0" case.

When a semi-continuous variable's interval already contains 0, simplifying it to continuous is mathematically correct and expected behavior, not a warning condition. CUOPT_LOG_INFO or CUOPT_LOG_DEBUG would be more appropriate here since this is informational, not indicative of a potential problem.

💡 Suggested change
     if (!needs_binary[s]) {
       // 0 already lies in [L, U], so the SC disjunction is just the interval itself.
-      CUOPT_LOG_WARN(
+      CUOPT_LOG_INFO(
         "SC var %d interval [%.6g, %.6g] already contains 0; treating it as continuous",
         idx,
         L,
         orig_u);
       var_types[idx] = var_t::CONTINUOUS;
       continue;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp/src/mip_heuristics/presolve/semi_continuous.cu` around lines 158 - 167,
The log here treats an expected simplification as a warning; replace the
CUOPT_LOG_WARN call in the block that checks needs_binary[s] with an
informational log (e.g., CUOPT_LOG_INFO or CUOPT_LOG_DEBUG) so that when 0 ∈ [L,
orig_u] and you set var_types[idx] = var_t::CONTINUOUS the message reflects
normal behavior; update the logging macro referenced around needs_binary[s],
idx, L, orig_u accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cpp/src/mip_heuristics/presolve/semi_continuous.cu`:
- Around line 158-167: The log here treats an expected simplification as a
warning; replace the CUOPT_LOG_WARN call in the block that checks
needs_binary[s] with an informational log (e.g., CUOPT_LOG_INFO or
CUOPT_LOG_DEBUG) so that when 0 ∈ [L, orig_u] and you set var_types[idx] =
var_t::CONTINUOUS the message reflects normal behavior; update the logging macro
referenced around needs_binary[s], idx, L, orig_u accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 09a2b50c-bd81-4d85-9037-c0b7cf1874a3

📥 Commits

Reviewing files that changed from the base of the PR and between 70fbadf and 25cd18b.

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

Copy link
Copy Markdown
Contributor

@mlubin mlubin left a comment

Choose a reason for hiding this comment

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

Check the AI comments too, some of them seem relevant.

Comment thread cpp/include/cuopt/linear_programming/optimization_problem_utils.hpp
Comment thread cpp/src/mip_heuristics/presolve/semi_continuous.cu
Comment thread cpp/src/mip_heuristics/presolve/semi_continuous.cu
// Ensure objective and variable arrays are sized to n_orig
if (obj_c.empty()) { obj_c.assign(n_orig, f_t(0)); }

// 5. Count how many SC vars truly need the binary-variable reformulation.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this something that might already get cleaned up by presolve? It seems like a rare case; probably a modeling error, that we don't need to optimize for.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think he needs to know how many of these semicontinuous variables will need binaries.

I do agree that it is probably pretty rare that we are able to convert the semicontinuous to continuous.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Papilo does not handle semi continuous vars so we have to handle the potential conversion to continuous here. It is a rare case but handled by other solvers and cheap to do.

Comment thread cpp/src/mip_heuristics/presolve/semi_continuous.cuh
Comment thread cpp/src/mip_heuristics/presolve/semi_continuous.cuh
Comment thread cpp/src/pdlp/utilities/problem_checking.cu
Comment thread cpp/src/pdlp/solve.cu
Comment thread cpp/tests/mip/semi_continuous_test.cu
Copy link
Copy Markdown

@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)
cpp/src/mip_heuristics/solve.cu (1)

330-337: ⚠️ Potential issue | 🟠 Major

Count SC reformulation against settings.time_limit.

reformulate_semi_continuous() can still spend real solve time before timer_t is created on Line 364. With small time limits, the solve can overrun the user budget before timing starts, and the reported presolve time omits the SC work.

Proposed fix
-    // Reformulate semi-continuous variables (x = 0 OR L <= x <= U) before Papilo presolve.
+    auto timer = timer_t(time_limit);
+
+    // Reformulate semi-continuous variables (x = 0 OR L <= x <= U) before Papilo presolve.
     // Uses GPU bounds propagation to derive tight upper bounds for SC vars with infinite UB.
     // Track n_orig so that auxiliary binary variables added by reformulation can be stripped
     // from the solution before returning it to the caller.
     const i_t n_orig_before_sc = op_problem.get_n_variables();
     std::vector<uint8_t> sc_used_fallback_big_m;
     const bool had_sc =
       detail::reformulate_semi_continuous(op_problem, settings, &sc_used_fallback_big_m);
@@
-    auto timer = timer_t(time_limit);
     if (settings.mip_scaling != CUOPT_MIP_SCALING_OFF) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cpp/src/mip_heuristics/solve.cu` around lines 330 - 337, The semi-continuous
reformulation (detail::reformulate_semi_continuous) runs before the presolve
timer is created so its time isn't charged to settings.time_limit; move or
create the presolve timer (timer_t) before invoking
detail::reformulate_semi_continuous (or otherwise start timing there) so the
work done by reformulate_semi_continuous is counted against settings.time_limit;
ensure the same timer is used for subsequent presolve work and keep tracking of
n_orig_before_sc, sc_used_fallback_big_m, op_problem and settings unchanged.
🤖 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/grpc/client/solve_remote.cpp`:
- Around line 214-221: The check that disables remote MIP callbacks uses raw
variable enums from cpu_problem.get_variable_types_host() and thus sees
SEMI_CONTINUOUS variables even when solve_mip() would have normalized zero-LB
semi-continuous variables to CONTINUOUS; update the condition so it mirrors that
normalization: compute the effective variable types after applying the same
zero-LB SC normalization (or filter out SEMI_CONTINUOUS entries with lb == 0)
and base has_sc_variables on that normalized view before deciding to clear
mip_callbacks (referencing cpu_problem.get_variable_types_host(),
var_t::SEMI_CONTINUOUS, solve_mip(), and mip_callbacks to locate the logic).

---

Duplicate comments:
In `@cpp/src/mip_heuristics/solve.cu`:
- Around line 330-337: The semi-continuous reformulation
(detail::reformulate_semi_continuous) runs before the presolve timer is created
so its time isn't charged to settings.time_limit; move or create the presolve
timer (timer_t) before invoking detail::reformulate_semi_continuous (or
otherwise start timing there) so the work done by reformulate_semi_continuous is
counted against settings.time_limit; ensure the same timer is used for
subsequent presolve work and keep tracking of n_orig_before_sc,
sc_used_fallback_big_m, op_problem and settings unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9e4ef575-e407-4ac0-8487-2bbe053e8a51

📥 Commits

Reviewing files that changed from the base of the PR and between 25cd18b and 7dd533b.

📒 Files selected for processing (4)
  • cpp/include/cuopt/linear_programming/mip/solver_settings.hpp
  • cpp/src/grpc/client/solve_remote.cpp
  • cpp/src/mip_heuristics/presolve/semi_continuous.cu
  • cpp/src/mip_heuristics/solve.cu

Comment on lines +214 to +221
const auto var_types = cpu_problem.get_variable_types_host();
const bool has_sc_variables =
thrust::count(var_types.begin(), var_types.end(), var_t::SEMI_CONTINUOUS) > 0;
if (has_sc_variables && !mip_callbacks.empty()) {
CUOPT_LOG_WARN(
"Disabling remote MIP get/set callbacks: semi-continuous models are not "
"supported with callbacks");
mip_callbacks.clear();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Mirror zero-LB SC normalization before disabling remote callbacks.

The local solve_mip() path first converts SEMI_CONTINUOUS variables with lb == 0 into CONTINUOUS, so those models keep callback support. This branch checks raw variable types only, so the same model will lose callbacks remotely even though it is normalized away on the GPU path. Base has_sc_variables on the post-normalization condition instead of the raw enum values.

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

In `@cpp/src/grpc/client/solve_remote.cpp` around lines 214 - 221, The check that
disables remote MIP callbacks uses raw variable enums from
cpu_problem.get_variable_types_host() and thus sees SEMI_CONTINUOUS variables
even when solve_mip() would have normalized zero-LB semi-continuous variables to
CONTINUOUS; update the condition so it mirrors that normalization: compute the
effective variable types after applying the same zero-LB SC normalization (or
filter out SEMI_CONTINUOUS entries with lb == 0) and base has_sc_variables on
that normalized view before deciding to clear mip_callbacks (referencing
cpu_problem.get_variable_types_host(), var_t::SEMI_CONTINUOUS, solve_mip(), and
mip_callbacks to locate the logic).

Comment thread cpp/include/cuopt/linear_programming/mip/solver_settings.hpp
Comment thread cpp/include/cuopt/linear_programming/constants.h
const bool has_sc_variables =
thrust::count(var_types.begin(), var_types.end(), var_t::SEMI_CONTINUOUS) > 0;
if (has_sc_variables && !mip_callbacks.empty()) {
CUOPT_LOG_WARN(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What's the issue with semicontinuous variables and callbacks? The semicontinuous variable should remain the same after translation right? You just add an extra constraint. So as long as you are able to uncrush the presolved solution I think you should be able to output a solution with semicontinuous variables to the user. Am I missing something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just wanted to keep the change minimal, we can support it.

op_relaxed.set_variable_upper_bounds(relaxed_ub.data(), n_orig);
}

// 3. Run GPU bounds propagation on the relaxed problem to tighten UBs.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it worth running all of bound strengthening? Perhaps we should restrict ourselves to just the constraints containing semicontinuous variables and the variables in those constraints.

Copy link
Copy Markdown
Contributor Author

@hlinsen hlinsen Apr 17, 2026

Choose a reason for hiding this comment

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

I am not sure, if it's time consuming but we can do what you suggested for sure. It will require some problem manipulation or api change in bound strenghtening which I like to avoid to reduce code complexity.

Comment thread cpp/src/mip_heuristics/solve.cu

// This is required as user might forget to set some fields
normalize_zero_lb_semi_continuous(op_problem);
if (has_semi_continuous_variables(op_problem) && !settings.initial_solutions.empty()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why can't we make the initial solution work? We can just look at the value of the semicontinuous variable to derive the value of the binary variable associated with it.

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

Labels

feature request New feature or request non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEA] Add support for semi-continuous variables

4 participants