Skip to content

Detect when MIP objectives must move in discrete steps. Tighten the dual bound using this info.#1239

Open
chris-maes wants to merge 1 commit into
NVIDIA:release/26.06from
chris-maes:objective_step
Open

Detect when MIP objectives must move in discrete steps. Tighten the dual bound using this info.#1239
chris-maes wants to merge 1 commit into
NVIDIA:release/26.06from
chris-maes:objective_step

Conversation

@chris-maes
Copy link
Copy Markdown
Contributor

This PR recognizes when the objective for a MIP can only move in discrete steps or increments.

Integer variables l_j <= x_j <= u_j, are represented as x_j = k * 1 + l_j, where k in {0, ..., u_j - l_j}
You can then use equality constraints to try put other continuous variables in the form: y_j = k * s_j + o_j. And eventually show that an objective containing a mix of integer and continuous variables must also be in the form: k * step + offset (for an integer k)

Using this allows you to improve your lower bound and stop early. For example, let's say objective has to be a multiple of 1/2. You have an upper bound of 2 and a lower bound of 1.7. You can declare optimality already, since the next best objective is 1.5 and your lower bound is already better than that.

This structure appears in 30/240 MIPLIB benchmark problems. So this is fairly common.

5 minute MIPLIB benchmark run on GH200:

  • 6% improvement in time to optimality
  • 1% improvement in MIP gap
  • +3 optimal (fiball mzzv42z, rail507)

@chris-maes chris-maes requested a review from a team as a code owner May 19, 2026 03:17
@chris-maes chris-maes requested review from kaatish and rg20 May 19, 2026 03:17
@copy-pr-bot
Copy link
Copy Markdown

copy-pr-bot Bot commented May 19, 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.

@chris-maes chris-maes added non-breaking Introduces a non-breaking change improvement Improves an existing functionality labels May 19, 2026
@chris-maes chris-maes added this to the 26.06 milestone May 19, 2026
@chris-maes chris-maes self-assigned this May 19, 2026
@chris-maes chris-maes requested a review from nguidotti May 19, 2026 03:20
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces objective-step tightening: a lattice-aware bounding technique that tightens branch-and-bound node bounds and LP cutoffs when the MIP objective has a periodic structure (step size and bias). The change threads a new objective_step_t type through problem data structures, computes it via GCD or lattice propagation, and uses it to refine B&B decisions.

Changes

Objective Step Tightening

Layer / File(s) Summary
Objective step type and parameter contracts
cpp/src/dual_simplex/user_problem.hpp, cpp/include/cuopt/linear_programming/constants.h, cpp/include/cuopt/linear_programming/mip/solver_settings.hpp, cpp/src/math_optimization/solver_settings.cu
objective_step_t<f_t> defines lattice structure with step_size, bias, and has_step() predicate. Configuration constant CUOPT_MIP_OBJECTIVE_STEP and solver parameter objective_step enable/disable the feature.
Problem data structure threading
cpp/src/dual_simplex/presolve.hpp, cpp/src/dual_simplex/presolve.cpp, cpp/src/mip_heuristics/problem/problem.cuh
objective_step member is added to user_problem_t, lp_problem_t, and problem_t; presolve copies user objective-step into internal problem; get_objective_step() accessor exposed for downstream queries.
Objective step computation via GCD and lattice propagation
cpp/src/mip_heuristics/problem/problem.cu
compute_objective_step() computes objective lattice: GCD-based for all-integer objectives; propagate_lattice() iteratively solves for per-variable rational lattice structure from constraints for mixed-integer cases.
Branch-and-bound node bounding with objective step
cpp/src/branch_and_bound/branch_and_bound.cpp, cpp/src/mip_heuristics/solver.cu
update_tree_impl snaps node lower bounds to feasible lattice points; solve_node_lp adjusts LP cutoffs using floor/ceil lattice arithmetic with dual tolerances; run_solver() propagates objective step into B&B problem context when enabled.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

improvement

Suggested reviewers

  • hlinsen
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% 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 accurately captures the core change: detecting when MIP objectives move in discrete steps and using this information to tighten the dual bound.
Description check ✅ Passed The description clearly explains the mathematical concept, implementation approach, and measurable improvements, directly relating to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cpp/src/branch_and_bound/branch_and_bound.cpp`:
- Around line 1230-1237: The code computes a tightened lattice-aware lower bound
into node_ptr->lower_bound (inside the
original_lp_.objective_step/objective_is_integral branches) but the subsequent
fathom/branch decision still uses leaf_obj; update the branch/fathom comparisons
to use node_ptr->lower_bound instead of leaf_obj so decisions are driven by the
tightened bound (replace occurrences of leaf_obj in the fathom/branch checks
near the first block and also in the similar logic in the 1247-1273 section);
ensure you keep the same tolerance logic (settings_.integer_tol) and the
existing comparison direction when swapping to node_ptr->lower_bound.
- Around line 1354-1365: The current cutoff computation in the objective_step
and integral branches subtracts an extra 1 (via (k - 1) and -1) which drops the
next attainable lattice value when cutoff is off-lattice; to fix, compute the
lattice index k = std::floor((cutoff - bias) / step + settings_.integer_tol)
(for objective_step) and use lp_settings.cut_off = k * step + bias +
settings_.dual_tol (remove the "-1" adjustment), and in the
original_lp_.objective_is_integral fallback use lp_settings.cut_off =
std::floor(cutoff + settings_.integer_tol) + settings_.dual_tol (remove the
"-1"); update code around original_lp_.objective_step, lp_settings.cut_off,
settings_.integer_tol, settings_.dual_tol, original_lp_.objective_is_integral
and k accordingly.

In `@cpp/src/mip_heuristics/problem/problem.cu`:
- Around line 1498-1504: Guard against non-finite lower bounds before converting
to a rational when setting lattice bias: in the block that assigns step_r[j] and
bias_r[j] (references: step_r, bias_r, var_bounds, get_lower,
rat_t::from_double) check that the double lower = get_lower(var_bounds[j]) is
finite (e.g. std::isfinite(lower)); if finite set bias_r[j] =
rat_t::from_double(lower), otherwise set bias_r[j] to a safe default such as
rat_t::zero() (or skip bias initialization) to avoid NaN; apply the same
finite-check logic to the other occurrence that forms lattice bias (the
assignment around the 1685-1691 region) so both places avoid converting ±inf.

In `@cpp/src/mip_heuristics/problem/problem.cuh`:
- Line 332: The new member objective_step (type
cuopt::linear_programming::dual_simplex::objective_step_t<f_t>) is not being
copied by the custom copy constructors in problem.cu, causing copied Problem
instances to lose objective-step metadata; update the copy constructor(s) and
copy-assignment (in cpp/src/mip_heuristics/problem/problem.cu) to explicitly
initialize/assign objective_step from the source object (use the member
initializer list or copy assignment) so the objective_step state is preserved
when copying Problem instances.

In `@cpp/src/mip_heuristics/solver.cu`:
- Around line 297-305: The code forwards objective_step unconditionally but
deterministic node-solve paths still use worker.local_upper_bound + dual_tol
instead of the lattice/integral cutoff; either restrict the propagation/logging
to modes that support the new cutoff or apply the same cutoff math in the
deterministic solvers. Update the block that sets
branch_and_bound_problem.objective_step (and the CUOPT_LOG_INFO) to check the
solver mode (use the same mode flag used for non-deterministic runs) before
assigning/logging objective_step, or alternatively modify the deterministic
solve path that computes the cutoff (references: worker.local_upper_bound,
dual_tol, and the deterministic node-solve functions) to use
context.problem_ptr->get_objective_step() and its lattice/integral cutoff
calculation so deterministic runs observe identical cutoff behavior. Ensure the
chosen change keeps behavior consistent across modes and remove duplicate
hard-coded cutoff logic.
🪄 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: Enterprise

Run ID: a738089d-fdd2-4825-88a9-a94c113912ab

📥 Commits

Reviewing files that changed from the base of the PR and between ef4aae7 and c75eb8e.

📒 Files selected for processing (10)
  • cpp/include/cuopt/linear_programming/constants.h
  • cpp/include/cuopt/linear_programming/mip/solver_settings.hpp
  • cpp/src/branch_and_bound/branch_and_bound.cpp
  • cpp/src/dual_simplex/presolve.cpp
  • cpp/src/dual_simplex/presolve.hpp
  • cpp/src/dual_simplex/user_problem.hpp
  • cpp/src/math_optimization/solver_settings.cu
  • cpp/src/mip_heuristics/problem/problem.cu
  • cpp/src/mip_heuristics/problem/problem.cuh
  • cpp/src/mip_heuristics/solver.cu

Comment on lines +1230 to 1237
if (original_lp_.objective_step.has_step()) {
f_t step = original_lp_.objective_step.step_size;
f_t bias = original_lp_.objective_step.bias;
// Round up to next value on the lattice: k * step + bias >= leaf_obj
f_t k = std::ceil((leaf_obj - bias) / step - settings_.integer_tol);
node_ptr->lower_bound = k * step + bias;
} else if (original_lp_.objective_is_integral) {
node_ptr->lower_bound = std::ceil(leaf_obj - settings_.integer_tol);
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 19, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fathom against the tightened node bound, not the raw LP objective.

After Lines 1230-1237, node_ptr->lower_bound may already be snapped up to the incumbent lattice value, but the branch/fathom check still uses leaf_obj. That keeps branching nodes that cannot produce a strictly improving integer solution anymore (for example, UB = 2, step = 0.5, leaf_obj = 1.7 snaps to LB = 2.0 and still branches here). Drive this decision from the tightened bound instead.

Also applies to: 1247-1273

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/src/branch_and_bound/branch_and_bound.cpp` around lines 1230 - 1237, The
code computes a tightened lattice-aware lower bound into node_ptr->lower_bound
(inside the original_lp_.objective_step/objective_is_integral branches) but the
subsequent fathom/branch decision still uses leaf_obj; update the branch/fathom
comparisons to use node_ptr->lower_bound instead of leaf_obj so decisions are
driven by the tightened bound (replace occurrences of leaf_obj in the
fathom/branch checks near the first block and also in the similar logic in the
1247-1273 section); ensure you keep the same tolerance logic
(settings_.integer_tol) and the existing comparison direction when swapping to
node_ptr->lower_bound.

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.

Maybe we should apply this directly to the leaf_obj and then update the node_ptr->lower_bound.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@nguidotti, that's a cleaner approach. Snapping leaf_obj in-place immediately after the update_pseudo_costs and graphviz calls (which should continue to reflect the raw LP objective) means every downstream use — including the handle_integer_solution path and the fathom check — automatically picks up the tightened value without touching the comparison expressions:

f_t leaf_obj = compute_objective(leaf_problem, leaf_solution.x);

policy.graphviz(search_tree, node_ptr, "lower bound", leaf_obj);   // raw LP obj
policy.update_pseudo_costs(node_ptr, leaf_obj);                     // raw LP obj

// Snap leaf_obj to the feasible lattice before any bound/fathom decisions
if (original_lp_.objective_step.has_step()) {
  f_t step = original_lp_.objective_step.step_size;
  f_t bias = original_lp_.objective_step.bias;
  f_t k    = std::ceil((leaf_obj - bias) / step - settings_.integer_tol);
  leaf_obj = k * step + bias;
} else if (original_lp_.objective_is_integral) {
  leaf_obj = std::ceil(leaf_obj - settings_.integer_tol);
}

node_ptr->lower_bound = leaf_obj;   // tightened
// ... num_frac == 0 check, fathom check — all use tightened leaf_obj

The only ordering constraint to preserve is that graphviz and update_pseudo_costs are called before the snap, which they already are. This resolves the original concern more elegantly. 🐇


🧠 Learnings used
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 922
File: cpp/src/branch_and_bound/pseudo_costs.cpp:85-87
Timestamp: 2026-03-03T15:03:14.218Z
Learning: In cuOpt, the dual simplex method is dual-feasible throughout execution in the files under cpp/src/dual_simplex/ and cpp/src/branch_and_bound/. When a solver terminates early with status ITERATION_LIMIT, the current objective value is still a valid (lower) bound for minimization, though looser than the optimum. Treat ITERATION_LIMIT results as safe for strong branching bound tightening, fixings, and cutoff logic in these modules. Apply this guideline to all cpp/src/branch_and_bound and related dual_simplex implementations (i.e., all .cpp files under cpp/src/branch_and_bound and cpp/src/dual_simplex).

Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 986
File: cpp/src/branch_and_bound/branch_and_bound.cpp:8-8
Timestamp: 2026-03-23T11:33:23.998Z
Learning: In this repo (NVIDIA/cuopt), treat nvcc as the supported CUDA toolchain; clang-based compilation/support is not required and may fail/break. During code reviews, do NOT request code changes or add blocking comments for errors that appear only under clang (e.g., header-resolution failures such as 'utilities/determinism_log.hpp not found')—these can be toolchain-related rather than real source issues.

Learnt from: bdice
Repo: NVIDIA/cuopt PR: 1035
File: cpp/tests/utilities/base_fixture.hpp:29-47
Timestamp: 2026-04-19T16:49:17.616Z
Learning: In NVIDIA/cuopt (and the upstream rapidsai/rmm) after the RMM → CCCL migration, the rmm::mr resource adaptors (e.g., rmm::mr::pool_memory_resource and rmm::mr::binning_memory_resource) are now owning: they take/hold their upstream resources by value instead of non-owning references. Therefore, direct construction of adaptor chains from temporaries/local values (e.g., pool_memory_resource(make_async(), size)) is safe and should NOT be flagged as potential dangling/lifetime bugs. Also, rmm::mr::make_owning_wrapper is no longer needed/available for this owning design, so do not suggest it in this codepath.

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.

Probably we should distinguish between the objective at the node (leaf_obj) and the lower bound coming from integrality or objective steps. To me this suggests that we use node_ptr->lower_bound rather than leaf_obj in the fathom check.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Comment on lines +1354 to +1365
if (original_lp_.objective_step.has_step()) {
f_t step = original_lp_.objective_step.step_size;
f_t bias = original_lp_.objective_step.bias;
// Any improving feasible solution must have objective <= cutoff - step.
f_t k = std::floor((cutoff - bias) / step + settings_.integer_tol);
lp_settings.cut_off = (k - 1) * step + bias + settings_.dual_tol;
} else if (original_lp_.objective_is_integral) {
// If the objective is integral, any feasible solution should produce an upper bound that is
// (approximately) integral. We add a small tolerance and floor this value to get an integer,
// we then subtract 1, to stop simplex on problems that cannot improve the primal objective.
lp_settings.cut_off =
std::floor(cutoff + settings_.integer_tol) - 1 + settings_.dual_tol;
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 19, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The cutoff formula drops one attainable value when cutoff is off-lattice.

(floor((cutoff - bias) / step + tol) - 1) * step + bias is only correct when cutoff is already on the objective lattice. If the incumbent is between lattice points—or just slightly below one because upper_bound_ stores the raw compute_objective(...) sum—you skip the next improving objective as well. The same off-by-one exists in the integral fallback.

Possible fix
   if (original_lp_.objective_step.has_step()) {
     f_t step = original_lp_.objective_step.step_size;
     f_t bias = original_lp_.objective_step.bias;
-    // Any improving feasible solution must have objective <= cutoff - step.
-    f_t k               = std::floor((cutoff - bias) / step + settings_.integer_tol);
+    // Largest lattice value strictly below `cutoff`.
+    f_t k               = std::ceil((cutoff - bias) / step - settings_.integer_tol);
     lp_settings.cut_off = (k - 1) * step + bias + settings_.dual_tol;
   } else if (original_lp_.objective_is_integral) {
-    lp_settings.cut_off =
-      std::floor(cutoff + settings_.integer_tol) - 1 + settings_.dual_tol;
+    lp_settings.cut_off =
+      std::ceil(cutoff - settings_.integer_tol) - 1 + settings_.dual_tol;
   } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/src/branch_and_bound/branch_and_bound.cpp` around lines 1354 - 1365, The
current cutoff computation in the objective_step and integral branches subtracts
an extra 1 (via (k - 1) and -1) which drops the next attainable lattice value
when cutoff is off-lattice; to fix, compute the lattice index k =
std::floor((cutoff - bias) / step + settings_.integer_tol) (for objective_step)
and use lp_settings.cut_off = k * step + bias + settings_.dual_tol (remove the
"-1" adjustment), and in the original_lp_.objective_is_integral fallback use
lp_settings.cut_off = std::floor(cutoff + settings_.integer_tol) +
settings_.dual_tol (remove the "-1"); update code around
original_lp_.objective_step, lp_settings.cut_off, settings_.integer_tol,
settings_.dual_tol, original_lp_.objective_is_integral and k accordingly.

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.

How could cutoff not be on the objective lattice? Cut off is equal to the objective of the best integer feasible solution.

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, you're absolutely right — I missed that. Since upper_bound_ is always the objective of the best integer feasible solution found so far, and integer feasible solutions necessarily have objectives on the lattice (that's precisely what the lattice detection establishes), cutoff is always a lattice point. So:

  • floor((cutoff - bias) / step + tol) gives the exact index k with k * step + bias = cutoff, and
  • (k - 1) * step + bias is the largest lattice value strictly below cutoff — which is the correct tightest cutoff.

The same reasoning holds for the objective_is_integral fallback: the incumbent is integral, so floor(cutoff + tol) - 1 = cutoff - 1 is exactly right.

My concern was unfounded. Sorry for the noise!


🧠 Learnings used
Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 922
File: cpp/src/branch_and_bound/pseudo_costs.cpp:85-87
Timestamp: 2026-03-03T15:03:14.218Z
Learning: In cuOpt, the dual simplex method is dual-feasible throughout execution in the files under cpp/src/dual_simplex/ and cpp/src/branch_and_bound/. When a solver terminates early with status ITERATION_LIMIT, the current objective value is still a valid (lower) bound for minimization, though looser than the optimum. Treat ITERATION_LIMIT results as safe for strong branching bound tightening, fixings, and cutoff logic in these modules. Apply this guideline to all cpp/src/branch_and_bound and related dual_simplex implementations (i.e., all .cpp files under cpp/src/branch_and_bound and cpp/src/dual_simplex).

Learnt from: aliceb-nv
Repo: NVIDIA/cuopt PR: 986
File: cpp/src/branch_and_bound/branch_and_bound.cpp:8-8
Timestamp: 2026-03-23T11:33:23.998Z
Learning: In this repo (NVIDIA/cuopt), treat nvcc as the supported CUDA toolchain; clang-based compilation/support is not required and may fail/break. During code reviews, do NOT request code changes or add blocking comments for errors that appear only under clang (e.g., header-resolution failures such as 'utilities/determinism_log.hpp not found')—these can be toolchain-related rather than real source issues.

Learnt from: bdice
Repo: NVIDIA/cuopt PR: 1035
File: cpp/tests/utilities/base_fixture.hpp:29-47
Timestamp: 2026-04-19T16:49:17.616Z
Learning: In NVIDIA/cuopt (and the upstream rapidsai/rmm) after the RMM → CCCL migration, the rmm::mr resource adaptors (e.g., rmm::mr::pool_memory_resource and rmm::mr::binning_memory_resource) are now owning: they take/hold their upstream resources by value instead of non-owning references. Therefore, direct construction of adaptor chains from temporaries/local values (e.g., pool_memory_resource(make_async(), size)) is safe and should NOT be flagged as potential dangling/lifetime bugs. Also, rmm::mr::make_owning_wrapper is no longer needed/available for this owning design, so do not suggest it in this codepath.

Comment on lines +1498 to +1504
for (i_t j = 0; j < n_vars; ++j) {
bool is_int = (var_types[j] == var_t::INTEGER);
bool is_impl_int = (var_flags[j] & (i_t)problem_t<i_t, f_t>::VAR_IMPLIED_INTEGER) != 0;
if (is_int || is_impl_int) {
step_r[j] = {1, 1};
bias_r[j] = rat_t::from_double(get_lower(var_bounds[j]));
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 19, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guard non-finite lower bounds before lattice bias math.

Line 1503 and Line 1690 use lower bounds directly when forming lattice bias. If a bound is ±inf, objective-step metadata becomes invalid (NaN/undefined), which can corrupt pruning/tightening decisions.

💡 Suggested fix
--- a/cpp/src/mip_heuristics/problem/problem.cu
+++ b/cpp/src/mip_heuristics/problem/problem.cu
@@
   for (i_t j = 0; j < n_vars; ++j) {
     bool is_int      = (var_types[j] == var_t::INTEGER);
     bool is_impl_int = (var_flags[j] & (i_t)problem_t<i_t, f_t>::VAR_IMPLIED_INTEGER) != 0;
     if (is_int || is_impl_int) {
       step_r[j] = {1, 1};
-      bias_r[j] = rat_t::from_double(get_lower(var_bounds[j]));
+      const auto lb = get_lower(var_bounds[j]);
+      if (std::isfinite(lb)) {
+        bias_r[j] = rat_t::from_double(lb);
+      } else {
+        // Keep integrality information but avoid non-finite bias.
+        bias_r[j] = {0, 1};
+      }
     }
   }
@@
   if (all_obj_vars_integral) {
@@
     std::vector<f_t> nonzero_coefs;
     f_t bias = 0;
     for (i_t i = 0; i < n_variables; ++i) {
       if (h_obj_coefs[i] == 0) continue;
       f_t coef = h_obj_coefs[i];
       f_t lb   = get_lower(h_var_bounds[i]);
+      if (!std::isfinite(lb)) {
+        // Cannot derive a reliable lattice offset from non-finite bounds.
+        objective_step = {};
+        return;
+      }
       nonzero_coefs.push_back(coef);
       bias += coef * lb;
     }

Also applies to: 1685-1691

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/src/mip_heuristics/problem/problem.cu` around lines 1498 - 1504, Guard
against non-finite lower bounds before converting to a rational when setting
lattice bias: in the block that assigns step_r[j] and bias_r[j] (references:
step_r, bias_r, var_bounds, get_lower, rat_t::from_double) check that the double
lower = get_lower(var_bounds[j]) is finite (e.g. std::isfinite(lower)); if
finite set bias_r[j] = rat_t::from_double(lower), otherwise set bias_r[j] to a
safe default such as rat_t::zero() (or skip bias initialization) to avoid NaN;
apply the same finite-check logic to the other occurrence that forms lattice
bias (the assignment around the 1685-1691 region) so both places avoid
converting ±inf.

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.

Where does an infinite lower bound lead to a NaN?

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 — good question. The NaN path is clearest at Location 2 (compute_objective_step):

f_t bias = 0;
for (i_t i = 0; i < n_variables; ++i) {
    if (h_obj_coefs[i] == 0) continue;
    f_t coef = h_obj_coefs[i];
    f_t lb   = get_lower(h_var_bounds[i]);   // could be -inf
    nonzero_coefs.push_back(coef);
    bias += coef * lb;                        // → bias becomes -inf or +inf
}
// ...
objective_step.bias = std::fmod(bias, g);    // std::fmod(±inf, g) == NaN (IEEE 754)

std::fmod(±∞, finite_nonzero) returns NaN per the C++ / IEEE 754 standard, so if any objective variable has an unbounded lower bound, objective_step.bias becomes NaN and will corrupt the pruning/tightening logic downstream.

At Location 1 (propagate_lattice) the direct issue is slightly different — not NaN but undefined behavior: rat_t::from_double calls rational_approximation, which does (int64_t)std::floor(±inf), and casting infinity to int64_t is UB in C++. Depending on the platform this gives garbage integer values rather than NaN, but the downstream lattice propagation results would still be incorrect.

In short, the NaN you were asked about is real and concrete at Location 2 via std::fmod.

bool is_scaled_{false};
bool preprocess_called{false};
bool objective_is_integral{false};
cuopt::linear_programming::dual_simplex::objective_step_t<f_t> objective_step;
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 | ⚡ Quick win

Copy operations currently drop objective_step state.

Line 332 adds a persistent member, but the custom copy constructors in cpp/src/mip_heuristics/problem/problem.cu don’t initialize it, so copied problems silently reset objective-step tightening metadata.

💡 Suggested fix
--- a/cpp/src/mip_heuristics/problem/problem.cu
+++ b/cpp/src/mip_heuristics/problem/problem.cu
@@
 problem_t<i_t, f_t>::problem_t(const problem_t<i_t, f_t>& problem_)
   : original_problem_ptr(problem_.original_problem_ptr),
@@
     preprocess_called(problem_.preprocess_called),
     objective_is_integral(problem_.objective_is_integral),
+    objective_step(problem_.objective_step),
     lp_state(problem_.lp_state),
@@
 problem_t<i_t, f_t>::problem_t(const problem_t<i_t, f_t>& problem_,
                                const raft::handle_t* handle_ptr_)
   : original_problem_ptr(problem_.original_problem_ptr),
@@
     preprocess_called(problem_.preprocess_called),
     objective_is_integral(problem_.objective_is_integral),
+    objective_step(problem_.objective_step),
     lp_state(problem_.lp_state, handle_ptr),
@@
 problem_t<i_t, f_t>::problem_t(const problem_t<i_t, f_t>& problem_, bool no_deep_copy)
   : original_problem_ptr(problem_.original_problem_ptr),
@@
     preprocess_called(problem_.preprocess_called),
     objective_is_integral(problem_.objective_is_integral),
+    objective_step(problem_.objective_step),
     lp_state(problem_.lp_state),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/src/mip_heuristics/problem/problem.cuh` at line 332, The new member
objective_step (type
cuopt::linear_programming::dual_simplex::objective_step_t<f_t>) is not being
copied by the custom copy constructors in problem.cu, causing copied Problem
instances to lose objective-step metadata; update the copy constructor(s) and
copy-assignment (in cpp/src/mip_heuristics/problem/problem.cu) to explicitly
initialize/assign objective_step from the source object (use the member
initializer list or copy assignment) so the objective_step state is preserved
when copying Problem instances.

Comment on lines +297 to +305
if (context.settings.objective_step && context.problem_ptr->get_objective_step().has_step()) {
CUOPT_LOG_INFO("Objective step size %g, bias %g",
context.problem_ptr->get_objective_step().step_size,
context.problem_ptr->get_objective_step().bias);
}
branch_and_bound_problem.objective_is_integral = context.problem_ptr->is_objective_integral();
if (context.settings.objective_step) {
branch_and_bound_problem.objective_step = context.problem_ptr->get_objective_step();
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 19, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Deterministic mode still misses the tightened LP-cutoff half of this feature.

This now forwards objective_step for every B&B run, but the deterministic node-solve paths still hard-code worker.local_upper_bound + dual_tol and never apply the new lattice/integral cutoff calculation. That makes the same setting change search behavior by determinism mode. Either gate this propagation/logging to the supported modes or thread the cutoff logic through the deterministic solvers too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cpp/src/mip_heuristics/solver.cu` around lines 297 - 305, The code forwards
objective_step unconditionally but deterministic node-solve paths still use
worker.local_upper_bound + dual_tol instead of the lattice/integral cutoff;
either restrict the propagation/logging to modes that support the new cutoff or
apply the same cutoff math in the deterministic solvers. Update the block that
sets branch_and_bound_problem.objective_step (and the CUOPT_LOG_INFO) to check
the solver mode (use the same mode flag used for non-deterministic runs) before
assigning/logging objective_step, or alternatively modify the deterministic
solve path that computes the cutoff (references: worker.local_upper_bound,
dual_tol, and the deterministic node-solve functions) to use
context.problem_ptr->get_objective_step() and its lattice/integral cutoff
calculation so deterministic runs observe identical cutoff behavior. Ensure the
chosen change keeps behavior consistent across modes and remove duplicate
hard-coded cutoff logic.

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.

Yep. We will leave moving this into deterministic mode as a TODO.

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, understood — deferring deterministic-mode support for objective_step tightening makes sense for now. Would you like me to open a GitHub issue to track this TODO so it doesn't get lost? I can file it with a reference back to this PR.


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

@anandhkb anandhkb added the P0 label May 19, 2026
// Iteratively propagate
bool any_discovered = false;
bool changed = true;
while (changed) {
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.

We can keep changed per equation right? That way we only propagate on the equations that contain variables contained in changed equations in the previous iteration.

// Internally uses rational arithmetic (int64_t numerator/denominator pairs) to avoid
// floating-point GCD issues.
template <typename i_t, typename f_t, typename f_t2>
static bool propagate_lattice(i_t n_vars,
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.

Could you briefly explain the lattice propagation logic?

I think we can also divide this function a bit into logical pieces. I think we can also move the rat_t out.

Copy link
Copy Markdown
Contributor

@nguidotti nguidotti left a comment

Choose a reason for hiding this comment

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

Looks good to me! We can also use this for bound propagation in the future. Just a minor comment: if we have a dependency on boost, maybe we should use boost/rational.hpp instead of our own implementation.

Comment on lines +1230 to 1237
if (original_lp_.objective_step.has_step()) {
f_t step = original_lp_.objective_step.step_size;
f_t bias = original_lp_.objective_step.bias;
// Round up to next value on the lattice: k * step + bias >= leaf_obj
f_t k = std::ceil((leaf_obj - bias) / step - settings_.integer_tol);
node_ptr->lower_bound = k * step + bias;
} else if (original_lp_.objective_is_integral) {
node_ptr->lower_bound = std::ceil(leaf_obj - settings_.integer_tol);
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.

Maybe we should apply this directly to the leaf_obj and then update the node_ptr->lower_bound.

Comment thread cpp/src/mip_heuristics/problem/problem.cu
Comment thread cpp/src/mip_heuristics/problem/problem.cu
@rgsl888prabhu rgsl888prabhu changed the base branch from main to release/26.06 May 20, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Improves an existing functionality non-breaking Introduces a non-breaking change P0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants