Skip to content

Fix bug where batch PDLP for strong branching was running on problem without cuts#951

Open
chris-maes wants to merge 3 commits intoNVIDIA:mainfrom
chris-maes:strong_branching_batch_lp_translate_lp_problem
Open

Fix bug where batch PDLP for strong branching was running on problem without cuts#951
chris-maes wants to merge 3 commits intoNVIDIA:mainfrom
chris-maes:strong_branching_batch_lp_translate_lp_problem

Conversation

@chris-maes
Copy link
Contributor

@chris-maes chris-maes commented Mar 11, 2026

Thanks to Nicolas for discovering the issue.

We now translate the problem with cuts directly to a problem batch PDLP can solve.

@chris-maes chris-maes requested a review from a team as a code owner March 11, 2026 17:00
@chris-maes chris-maes requested review from hlinsen and rg20 March 11, 2026 17:00
@copy-pr-bot
Copy link

copy-pr-bot bot commented Mar 11, 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.

@Kh4ster Kh4ster added bug Something isn't working non-breaking Introduces a non-breaking change pdlp mip labels Mar 11, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

📝 Walkthrough

Walkthrough

Reworks strong_branching and simplex_problem_to_mps_data_model signatures to use lp_problem_t and pass explicit slack/root data (new_slacks, root_soln, root_obj, root_vstatus, edge_norms, and pseudo_costs_t& pc). Updates call sites in branch_and_bound to match the new parameter ordering.

Changes

Cohort / File(s) Summary
Signature & implementation — strong_branching / MPS conversion
cpp/src/branch_and_bound/pseudo_costs.hpp, cpp/src/branch_and_bound/pseudo_costs.cpp
Reworked strong_branching signature to accept lp_problem_t, simplex_solver_settings_t, start_time, new_slacks, var_types, root_soln, fractional, root_obj, root_vstatus, edge_norms, and pseudo_costs_t& pc. simplex_problem_to_mps_data_model now accepts lp_problem_t, new_slacks, root_soln, and outputs original_root_soln_x. Updated template instantiations and internal usage accordingly.
Call sites — branch_and_bound solve
cpp/src/branch_and_bound/branch_and_bound.cpp
Updated two strong_branching invocations: removed original_problem_ where appropriate, inserted new_slacks_ after start_time, and in the root-relaxation path appended root_objective_, root_vstatus_, edge_norms_, and pc_ to match the new parameter list.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main bug being fixed: batch PDLP for strong branching was incorrectly running on a problem without cuts, and this PR corrects that issue.
Description check ✅ Passed The description is directly related to the changeset, explaining the bug fix and the solution approach of translating the problem with cuts for batch PDLP.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

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

Tip

You can make CodeRabbit's review stricter and more nitpicky using the `assertive` profile, if that's what you prefer.

Change the reviews.profile setting to assertive to make CodeRabbit's nitpick more issues in your PRs.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cpp/src/branch_and_bound/branch_and_bound.cpp (1)

2409-2420: ⚠️ Potential issue | 🟠 Major

Batch PDLP still receives the cut-free root state.

remove_cuts(...) already ran on Lines 2339-2355, and fractional is recomputed after that on Lines 2360-2361. So this call still feeds the stripped original_lp_ / root_relax_soln_ / root_vstatus_ state into strong_branching, which means the new lp_problem_t-based path never sees the cut-strengthened root relaxation. If the goal of this PR is to score strong branches against the root LP with cuts, this needs to move before remove_cuts(...) or use a snapshot captured before that projection. 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/branch_and_bound/branch_and_bound.cpp` around lines 2409 - 2420,
strong_branching is being called with the cut-stripped state (original_lp_,
root_relax_soln_, root_vstatus_, fractional) after remove_cuts(...) runs, so the
branch scoring never sees the cut-strengthened root LP; move the
strong_branching<i_t,f_t>(...) invocation to run before remove_cuts(...) or else
capture a snapshot of the pre-remove_cuts state (including original_lp_,
root_relax_soln_, root_vstatus_, and the recomputed fractional) and pass that
snapshot into strong_branching so the function evaluates branches against the
cut-strengthened root relaxation.
🧹 Nitpick comments (1)
cpp/src/branch_and_bound/pseudo_costs.cpp (1)

243-279: Project the non-slack vectors with the same mask as the matrix.

A_no_slacks.remove_columns(cols_to_remove) is order-agnostic, but original_root_soln_x, objective, lower, and upper are still built by taking the first n entries. That keeps this helper coupled to the current “slacks are suffix columns” layout. Rebuilding those arrays with cols_to_remove would make the translator self-contained and safer if column insertion order ever changes.

♻️ Suggested direction
-  int n = lp.num_cols - new_slacks.size();
-  original_root_soln_x.resize(n);
+  int n = lp.num_cols - new_slacks.size();
+  std::vector<f_t> objective_no_slacks;
+  std::vector<f_t> lower_no_slacks;
+  std::vector<f_t> upper_no_slacks;
+  objective_no_slacks.reserve(n);
+  lower_no_slacks.reserve(n);
+  upper_no_slacks.reserve(n);
+  original_root_soln_x.clear();
+  original_root_soln_x.reserve(n);
@@
-  for (i_t j = 0; j < n; j++) {
-    original_root_soln_x[j] = root_soln[j];
-  }
+  for (i_t j = 0; j < lp.num_cols; ++j) {
+    if (cols_to_remove[j]) { continue; }
+    objective_no_slacks.push_back(lp.objective[j]);
+    lower_no_slacks.push_back(lp.lower[j]);
+    upper_no_slacks.push_back(lp.upper[j]);
+    original_root_soln_x.push_back(root_soln[j]);
+  }
@@
-  mps_model.set_objective_coefficients(lp.objective.data(), n);
+  mps_model.set_objective_coefficients(objective_no_slacks.data(), n);
@@
-  mps_model.set_variable_lower_bounds(lp.lower.data(), n);
-  mps_model.set_variable_upper_bounds(lp.upper.data(), n);
+  mps_model.set_variable_lower_bounds(lower_no_slacks.data(), n);
+  mps_model.set_variable_upper_bounds(upper_no_slacks.data(), n);

Based on learnings, "Reduce tight coupling between solver components (presolve, simplex, basis, barrier); increase modularity and reusability of optimization algorithms".

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

In `@cpp/src/branch_and_bound/pseudo_costs.cpp` around lines 243 - 279, The code
currently trims columns from A via A_no_slacks.remove_columns(cols_to_remove)
but still builds original_root_soln_x and passes lp.objective, lp.lower,
lp.upper by taking the first n entries, assuming slacks were trailing; change
this to project those vectors using the same cols_to_remove mask so their
entries align with the column order of A_no_slacks: build a
projected_original_root_soln_x by iterating over the full root_soln and copying
entries where cols_to_remove[j]==0 (maintaining original column order),
similarly construct projected_objective, projected_lower, and projected_upper
from lp.objective, lp.lower, lp.upper using cols_to_remove, then pass those
projected arrays to mps_model.set_objective_coefficients and
set_variable_lower_bounds/set_variable_upper_bounds and use
projected_original_root_soln_x instead of slicing the first n elements.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@cpp/src/branch_and_bound/branch_and_bound.cpp`:
- Around line 2409-2420: strong_branching is being called with the cut-stripped
state (original_lp_, root_relax_soln_, root_vstatus_, fractional) after
remove_cuts(...) runs, so the branch scoring never sees the cut-strengthened
root LP; move the strong_branching<i_t,f_t>(...) invocation to run before
remove_cuts(...) or else capture a snapshot of the pre-remove_cuts state
(including original_lp_, root_relax_soln_, root_vstatus_, and the recomputed
fractional) and pass that snapshot into strong_branching so the function
evaluates branches against the cut-strengthened root relaxation.

---

Nitpick comments:
In `@cpp/src/branch_and_bound/pseudo_costs.cpp`:
- Around line 243-279: The code currently trims columns from A via
A_no_slacks.remove_columns(cols_to_remove) but still builds original_root_soln_x
and passes lp.objective, lp.lower, lp.upper by taking the first n entries,
assuming slacks were trailing; change this to project those vectors using the
same cols_to_remove mask so their entries align with the column order of
A_no_slacks: build a projected_original_root_soln_x by iterating over the full
root_soln and copying entries where cols_to_remove[j]==0 (maintaining original
column order), similarly construct projected_objective, projected_lower, and
projected_upper from lp.objective, lp.lower, lp.upper using cols_to_remove, then
pass those projected arrays to mps_model.set_objective_coefficients and
set_variable_lower_bounds/set_variable_upper_bounds and use
projected_original_root_soln_x instead of slicing the first n elements.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c1065a0a-3686-4088-bff9-6c0dcd8e7f4f

📥 Commits

Reviewing files that changed from the base of the PR and between d862480 and 721a56a.

📒 Files selected for processing (3)
  • cpp/src/branch_and_bound/branch_and_bound.cpp
  • cpp/src/branch_and_bound/pseudo_costs.cpp
  • cpp/src/branch_and_bound/pseudo_costs.hpp

@anandhkb anandhkb added this to the 26.04 milestone Mar 11, 2026
@chris-maes
Copy link
Contributor Author

/ok to test 3aa1ea5

@chris-maes
Copy link
Contributor Author

/ok to test 4d51222

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cpp/src/branch_and_bound/pseudo_costs.cpp (1)

383-423: ⚠️ Potential issue | 🟠 Major

Handle infeasible Batch PDLP branches as +inf, not NaN.

The simplex fallback in this file treats an infeasible child as +inf and clamps the delta with std::max(obj - root_obj, 0.0). This path only accepts Optimal, so an infeasible down/up branch gets recorded as NaN and then ignored by update_pseudo_costs_from_strong_branching(). That drops exactly the strongest branching signal and can skew variable selection after cuts are added. Please map the PDLP infeasible termination to +inf here and keep the non-negative delta clamp in the Batch PDLP path as well.

I can help sketch a small termination_status -> strong_branch_delta helper once you confirm which PDLP infeasible enum(s) Batch PDLP returns here.

As per coding guidelines, "Validate algorithm correctness in optimization logic: simplex pivots, branch-and-bound decisions, routing heuristics, and constraint/objective handling must produce correct results".

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

In `@cpp/src/branch_and_bound/pseudo_costs.cpp` around lines 383 - 423, The batch
PDLP branch results currently map non-Optimal termination to NaN (via
obj_down/obj_up), which causes strong branching deltas to be ignored; change the
logic in the loop that computes obj_down/obj_up (the uses of
solutions.get_termination_status(...) and
solutions.get_dual_objective_value(...)) so that infeasible PDLP termination(s)
returned for a child are mapped to +infinity instead of NaN, then compute the
stored deltas pc.strong_branch_down[k] and pc.strong_branch_up[k] as
non-negative clamped values (e.g. max(obj - root_obj, 0.0)) like the simplex
fallback does; ensure you handle the two indices k and k + fractional.size()
consistently and keep update_pseudo_costs_from_strong_branching()’s expectations
intact.
🧹 Nitpick comments (1)
cpp/src/branch_and_bound/pseudo_costs.cpp (1)

243-277: Keep the reduced-space vectors aligned with the same slack mask.

A_no_slacks.remove_columns(cols_to_remove) supports arbitrary slack positions, but the objective, bounds, and original_root_soln_x are still taken as the first n entries and then indexed with original column ids. That only stays correct if every new_slacks entry is a suffix column. Please either compact those vectors with cols_to_remove too, or assert that invariant here before using prefix slices.

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

Also applies to: 364-367

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

In `@cpp/src/branch_and_bound/pseudo_costs.cpp` around lines 243 - 277, The
reduced-space data (original_root_soln_x, objective/bounds used for mps_model
via mps_model.set_objective_coefficients and
set_variable_lower_bounds/set_variable_upper_bounds) is being taken as the first
n entries but A_no_slacks.remove_columns(cols_to_remove) can remove arbitrary
columns (new_slacks), so either compact lp.objective, lp.lower, lp.upper and
root_soln into new vectors using the same cols_to_remove mask before slicing
(and fill original_root_soln_x from that compacted root vector), or add a clear
assertion that all indices in new_slacks form a suffix so the current
prefix-slice is valid; update the code paths around original_root_soln_x
assignment, A_no_slacks.remove_columns(cols_to_remove), and the calls to
mps_model.set_* to use the compacted vectors or fail fast if the suffix
invariant is not met.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@cpp/src/branch_and_bound/pseudo_costs.cpp`:
- Around line 383-423: The batch PDLP branch results currently map non-Optimal
termination to NaN (via obj_down/obj_up), which causes strong branching deltas
to be ignored; change the logic in the loop that computes obj_down/obj_up (the
uses of solutions.get_termination_status(...) and
solutions.get_dual_objective_value(...)) so that infeasible PDLP termination(s)
returned for a child are mapped to +infinity instead of NaN, then compute the
stored deltas pc.strong_branch_down[k] and pc.strong_branch_up[k] as
non-negative clamped values (e.g. max(obj - root_obj, 0.0)) like the simplex
fallback does; ensure you handle the two indices k and k + fractional.size()
consistently and keep update_pseudo_costs_from_strong_branching()’s expectations
intact.

---

Nitpick comments:
In `@cpp/src/branch_and_bound/pseudo_costs.cpp`:
- Around line 243-277: The reduced-space data (original_root_soln_x,
objective/bounds used for mps_model via mps_model.set_objective_coefficients and
set_variable_lower_bounds/set_variable_upper_bounds) is being taken as the first
n entries but A_no_slacks.remove_columns(cols_to_remove) can remove arbitrary
columns (new_slacks), so either compact lp.objective, lp.lower, lp.upper and
root_soln into new vectors using the same cols_to_remove mask before slicing
(and fill original_root_soln_x from that compacted root vector), or add a clear
assertion that all indices in new_slacks form a suffix so the current
prefix-slice is valid; update the code paths around original_root_soln_x
assignment, A_no_slacks.remove_columns(cols_to_remove), and the calls to
mps_model.set_* to use the compacted vectors or fail fast if the suffix
invariant is not met.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 622d9ba7-fa15-49ed-a2e5-aa51e05b3221

📥 Commits

Reviewing files that changed from the base of the PR and between 3aa1ea5 and 4d51222.

📒 Files selected for processing (1)
  • cpp/src/branch_and_bound/pseudo_costs.cpp

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants