Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions python/cuopt/cuopt/linear_programming/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -1910,7 +1910,11 @@ def relax(self):
def populate_solution(self, solution):
self.Status = solution.get_termination_status()
self.SolveTime = solution.get_solve_time()
self.warmstart_data = solution.get_pdlp_warm_start_data()
self.warmstart_data = (
solution.get_pdlp_warm_start_data()
if solution.problem_category == 0
else None
)

IsMIP = False
if solution.problem_category == 0:
Expand All @@ -1919,7 +1923,7 @@ def populate_solution(self, solution):
IsMIP = True
self.SolutionStats = self.dict_to_object(solution.get_milp_stats())
primal_sol = solution.get_primal_solution()
reduced_cost = solution.get_reduced_cost()
reduced_cost = solution.get_reduced_cost() if not IsMIP else None
if len(primal_sol) > 0:
for var in self.vars:
var.Value = primal_sol[var.index]
Expand Down
65 changes: 40 additions & 25 deletions python/cuopt/cuopt/linear_programming/solution/solution.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from cuopt.linear_programming.solver.solver_wrapper import (
Expand Down Expand Up @@ -167,25 +167,28 @@ def __init__(
self.problem_category = problem_category
self.primal_solution = primal_solution
self.dual_solution = dual_solution
self.pdlp_warm_start_data = PDLPWarmStartData(
current_primal_solution,
current_dual_solution,
initial_primal_average,
initial_dual_average,
current_ATY,
sum_primal_solutions,
sum_dual_solutions,
last_restart_duality_gap_primal_solution,
last_restart_duality_gap_dual_solution,
initial_primal_weight,
initial_step_size,
total_pdlp_iterations,
total_pdhg_iterations,
last_candidate_kkt_score,
last_restart_kkt_score,
sum_solution_weight,
iterations_since_last_restart,
)
if problem_category == ProblemCategory.LP:
self.pdlp_warm_start_data = PDLPWarmStartData(
current_primal_solution,
current_dual_solution,
initial_primal_average,
initial_dual_average,
current_ATY,
sum_primal_solutions,
sum_dual_solutions,
last_restart_duality_gap_primal_solution,
last_restart_duality_gap_dual_solution,
initial_primal_weight,
initial_step_size,
total_pdlp_iterations,
total_pdhg_iterations,
last_candidate_kkt_score,
last_restart_kkt_score,
sum_solution_weight,
iterations_since_last_restart,
)
else:
self.pdlp_warm_start_data = None
self._set_termination_status(termination_status)
self.error_status = error_status
self.error_message = error_message
Expand Down Expand Up @@ -216,8 +219,17 @@ def __init__(
def _set_termination_status(self, ts):
if self.problem_category == ProblemCategory.LP:
self.termination_status = LPTerminationStatus(ts)
else:
elif self.problem_category in (
ProblemCategory.MIP,
ProblemCategory.IP,
):
self.termination_status = MILPTerminationStatus(ts)
else:
raise ValueError(
f"Unknown problem_category: {self.problem_category!r}. "
"Expected one of ProblemCategory.LP, ProblemCategory.MIP, "
"ProblemCategory.IP."
)

def raise_if_milp_solution(self, function_name):
if self.problem_category in (ProblemCategory.MIP, ProblemCategory.IP):
Expand All @@ -242,7 +254,7 @@ def get_dual_solution(self):
Note: Applicable to only LP
Returns the dual solution as numpy.array with float64 type.
"""
self.raise_if_milp_solution(__name__)
self.raise_if_milp_solution("get_dual_solution")
return self.dual_solution

def get_primal_objective(self):
Expand All @@ -256,7 +268,7 @@ def get_dual_objective(self):
Note: Applicable to only LP
Returns the dual objective as a float64.
"""
self.raise_if_milp_solution(__name__)
self.raise_if_milp_solution("get_dual_objective")
return self.dual_objective

def get_termination_status(self):
Expand Down Expand Up @@ -325,14 +337,16 @@ def get_lp_stats(self):
Number of iterations the LP solver did before converging.
"""

self.raise_if_milp_solution(__name__)
self.raise_if_milp_solution("get_lp_stats")

return self.lp_stats

def get_reduced_cost(self):
"""
Note: Applicable to only LP
Returns the reduced cost as numpy.array with float64 type.
"""
self.raise_if_milp_solution("get_reduced_cost")
return self.reduced_cost

def get_pdlp_warm_start_data(self):
Expand All @@ -343,6 +357,7 @@ def get_pdlp_warm_start_data(self):

See `SolverSettings.set_pdlp_warm_start_data` for more details.
"""
self.raise_if_milp_solution("get_pdlp_warm_start_data")
return self.pdlp_warm_start_data

def get_milp_stats(self):
Expand Down Expand Up @@ -386,7 +401,7 @@ def get_milp_stats(self):
Number of simplex iterations performed during the MIP solve
"""

self.raise_if_lp_solution(__name__)
self.raise_if_lp_solution("get_milp_stats")

return self.milp_stats

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ def create_solution(sol):
milp_stats = get_if_attribute_is_valid_else_none(
sol.get_milp_stats
)
pdlpwarmstart_data = get_if_attribute_is_valid_else_none(
sol.get_pdlp_warm_start_data
)
Comment on lines +307 to +309
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check LP solution API guard implementation =="
if [ -f "python/cuopt/cuopt/linear_programming/solution/solution.py" ]; then
  sed -n '340,380p' python/cuopt/cuopt/linear_programming/solution/solution.py | nl -ba
else
  echo "File not found at expected path"
fi

echo
echo "== Check server create_solution call site =="
if [ -f "python/cuopt_server/cuopt_server/utils/linear_programming/solver.py" ]; then
  sed -n '270,335p' python/cuopt_server/cuopt_server/utils/linear_programming/solver.py | nl -ba
else
  echo "File not found at expected path"
fi

echo
echo "== Find all get_pdlp_warm_start_data usages in Python files =="
rg -n -C3 '\bget_pdlp_warm_start_data\s*\(' --type=py || echo "No matches found"

Repository: NVIDIA/cuopt

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check LP solution API guard implementation =="
if [ -f "python/cuopt/cuopt/linear_programming/solution/solution.py" ]; then
  cat -n python/cuopt/cuopt/linear_programming/solution/solution.py | sed -n '340,380p'
else
  echo "File not found at expected path"
fi

echo
echo "== Check server create_solution call site =="
if [ -f "python/cuopt_server/cuopt_server/utils/linear_programming/solver.py" ]; then
  cat -n python/cuopt_server/cuopt_server/utils/linear_programming/solver.py | sed -n '270,335p'
else
  echo "File not found at expected path"
fi

echo
echo "== Find all get_pdlp_warm_start_data usages in Python files =="
rg -n -C3 '\bget_pdlp_warm_start_data' --type=py || echo "No matches found"

Repository: NVIDIA/cuopt

Length of output: 12731


Guard LP-only warm-start getter against MILP status paths

Lines 307-309 call sol.get_pdlp_warm_start_data() unconditionally in a code block that handles both LP and MILP termination statuses (lines 278-280). The getter raises on MILP solutions, which the wrapper get_if_attribute_is_valid_else_none() cannot catch (only catches AttributeError). This causes MILP solves to fail with an unhandled exception instead of gracefully defaulting to None. Gate the call by problem category as shown in python/cuopt/cuopt/linear_programming/problem.py:1905.

Proposed fix
+            problem_category = sol.get_problem_category()
             pdlpwarmstart_data = get_if_attribute_is_valid_else_none(
-                sol.get_pdlp_warm_start_data
+                sol.get_pdlp_warm_start_data
+            ) if problem_category.name == "LP" else None
-            )
-            solution["problem_category"] = sol.get_problem_category().name
+            solution["problem_category"] = problem_category.name
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/cuopt_server/cuopt_server/utils/linear_programming/solver.py` around
lines 307 - 309, The call to sol.get_pdlp_warm_start_data is unguarded and
raises for MILP solutions; change the code to only invoke
get_pdlp_warm_start_data when the solved problem is an LP (i.e., check the
problem category on the solution/problem object first) and otherwise set
pdlpwarmstart_data to None; specifically, before calling
get_if_attribute_is_valid_else_none(sol.get_pdlp_warm_start_data) add a guard
that tests the problem category (e.g., compare
solution/problem.get_problem_category() or problem.category against the LP
enum/value used elsewhere) so the getter is never called on MILP paths.

solution["problem_category"] = sol.get_problem_category().name
solution["primal_solution"] = primal_solution
solution["dual_solution"] = dual_solution
Expand All @@ -318,8 +321,9 @@ def create_solution(sol):
solution["vars"] = sol.get_vars()
solution["lp_statistics"] = {} if lp_stats is None else lp_stats
solution["reduced_cost"] = reduced_cost

solution["pdlpwarmstart_data"] = extract_pdlpwarmstart_data(
sol.get_pdlp_warm_start_data()
pdlpwarmstart_data
)
solution["milp_statistics"] = (
{} if milp_stats is None else milp_stats
Expand Down