Skip to content
Open
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
195 changes: 195 additions & 0 deletions python/cuopt/cuopt/linear_programming/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,100 @@
import warnings


# ---- Display helpers for __str__/__repr__ ----

_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="}

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 | ⚡ Quick win

Fix import-time NameError in _SENSE_SYMBOLS initialization.

LE, GE, and EQ are referenced before they are defined, so importing this module will fail at Line 21.

Suggested fix
-_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="}
+_SENSE_SYMBOLS = {
+    CType.LE: "<=",
+    CType.GE: ">=",
+    CType.EQ: "==",
+}

If you want to keep helpers at the top of the file, an alternative is to key by raw codes:

-_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="}
+_SENSE_SYMBOLS = {"L": "<=", "G": ">=", "E": "=="}
🧰 Tools
🪛 Ruff (0.15.15)

[error] 21-21: Undefined name LE

(F821)


[error] 21-21: Undefined name GE

(F821)


[error] 21-21: Undefined name EQ

(F821)

🤖 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 `@python/cuopt/cuopt/linear_programming/problem.py` at line 21, The
_SENSE_SYMBOLS dictionary is created before the constants LE, GE, and EQ are
defined, causing an import-time NameError; fix by deferring its construction
until after those constants are declared (or by keying it with the raw
numeric/char codes used for LE/GE/EQ instead of the names), i.e., move or
rebuild _SENSE_SYMBOLS after the definitions of LE, GE, EQ (or replace keys with
the literal codes) so references in _SENSE_SYMBOLS resolve correctly.

Source: Linters/SAST tools

_TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"}


def _var_display_name(var):
"""Return the display name of a Variable."""
name = var.VariableName
if name:
return name
if getattr(var, "index", -1) >= 0:
return f"C{var.index}"
return f"V{id(var)}"


def _type_display(type_val):
"""Return a human-readable name for a variable type."""
if isinstance(type_val, VType):
return type_val.name
if isinstance(type_val, (bytes, bytearray)):
type_val = type_val.decode()
return _TYPE_NAMES.get(type_val, str(type_val))


class _ExprBuilder:
"""Build an algebraic string from a sequence of terms.

The first term is emitted without a sign; subsequent terms are joined
with ' + ' or ' - ' separators. A coefficient of 1.0 or -1.0 is
elided, so '1.0 * x' becomes 'x' and '-1.0 * x' becomes '-x'.
"""

def __init__(self):
self.parts = []

def add_linear(self, coef, var):
"""Add a linear term ``coef * var``."""
if coef == 0.0:
return
var_str = _var_display_name(var)
if coef == 1.0:
self._append(var_str, negative=False)
elif coef == -1.0:
self._append(var_str, negative=True)
else:
self._append(f"{abs(coef)} * {var_str}", negative=coef < 0)

def add_quadratic(self, coef, var1, var2):
"""Add a quadratic term ``coef * var1 * var2``."""
if coef == 0.0:
return
v1_str = _var_display_name(var1)
v2_str = _var_display_name(var2)
if v1_str == v2_str:
term_str = f"{v1_str}^2"
elif v1_str <= v2_str:
term_str = f"{v1_str} * {v2_str}"
else:
term_str = f"{v2_str} * {v1_str}"
if coef == 1.0:
self._append(term_str, negative=False)
elif coef == -1.0:
self._append(term_str, negative=True)
else:
self._append(f"{abs(coef)} * {term_str}", negative=coef < 0)

def add_constant(self, value):
"""Add a constant term."""
if value == 0.0:
return
self._append(f"{abs(value)}", negative=value < 0)

def _append(self, term, negative):
if not self.parts:
self.parts.append(f"-{term}" if negative else term)
else:
self.parts.append(f" - {term}" if negative else f" + {term}")

def build(self):
if not self.parts:
return "0.0"
return "".join(self.parts)


def _format_linear(vars, coeffs, constant):
"""Format a linear expression as an algebraic string."""
builder = _ExprBuilder()
for var, coef in zip(vars, coeffs):
builder.add_linear(coef, var)
builder.add_constant(constant)
return builder.build()


class VType(str, Enum):
"""
The type of a variable is continuous, integer, or semi-continuous.
Expand Down Expand Up @@ -335,6 +429,19 @@ def __eq__(self, other):
case _:
raise ValueError("Unsupported operation")

def __str__(self):
return _var_display_name(self)

def __repr__(self):
name = _var_display_name(self)
idx = getattr(self, "index", -1)
type_str = _type_display(self.VariableType)
return (
f"<cuopt.Variable '{name}' (index={idx}), "
f"type={type_str}, bounds=[{self.LB}, {self.UB}], "
f"value={self.Value}>"
)


class QuadraticExpression:
"""
Expand Down Expand Up @@ -889,6 +996,25 @@ def __ge__(self, other):
def __eq__(self, other):
raise ValueError("Equality constraints are not supported.")

def __str__(self):
builder = _ExprBuilder()
if self.qmatrix is not None:
for row, col, val in zip(
self.qmatrix.row, self.qmatrix.col, self.qmatrix.data
):
if val == 0.0:
continue
builder.add_quadratic(val, self.qvars[row], self.qvars[col])
for v1, v2, coef in zip(self.qvars1, self.qvars2, self.qcoefficients):
builder.add_quadratic(coef, v1, v2)
for var, coef in zip(self.vars, self.coefficients):
builder.add_linear(coef, var)
builder.add_constant(self.constant)
return builder.build()

def __repr__(self):
return f"<cuopt.QuadraticExpression: {self}>"


def _quadratic_expression_to_qcmatrix(expr, rhs):
"""Build QCMATRIX COO data for a quadratic row ``expr`` sense ``rhs``.
Expand Down Expand Up @@ -1280,6 +1406,12 @@ def __eq__(self, other):
expr = self - other
return Constraint(expr, EQ, 0.0)

def __str__(self):
return _format_linear(self.vars, self.coefficients, self.constant)

def __repr__(self):
return f"<cuopt.LinearExpression: {self}>"


class Constraint:
"""
Expand Down Expand Up @@ -1322,6 +1454,7 @@ def __init__(self, expr, sense, rhs, name=""):
self.ConstraintName = name
self.DualValue = float("nan")
self.Slack = float("nan")
self._expr = expr

if isinstance(expr, QuadraticExpression):
self.is_quadratic = True
Expand Down Expand Up @@ -1397,6 +1530,17 @@ def compute_slack(self):

return self.RHS - lhs

def __str__(self):
sense_str = _SENSE_SYMBOLS.get(self.Sense, str(self.Sense))
lhs = str(self._expr) if self._expr is not None else "0.0"
expr_constant = getattr(self._expr, "constant", 0.0) or 0.0
user_rhs = self.RHS + expr_constant
return f"{lhs} {sense_str} {user_rhs}"

def __repr__(self):
name = self.ConstraintName if self.ConstraintName else "<unnamed>"
return f"<cuopt.Constraint '{name}': {self}>"


class Problem:
"""
Expand Down Expand Up @@ -2219,3 +2363,54 @@ def solve(self, settings=solver_settings.SolverSettings()):
# Post Solve
self.populate_solution(solution)
return solution

def __repr__(self):
name = self.Name if self.Name else "<unnamed>"
return (
f"<cuopt.Problem '{name}' "
f"({len(self.vars)} vars, {len(self.constrs)} constrs, "
f"IsMIP={self.IsMIP})>"
)

def __str__(self):
lines = []
name = self.Name if self.Name else "<unnamed>"
lines.append(f"Problem: {name}")
sense_str = "MINIMIZE" if self.ObjSense == MINIMIZE else "MAXIMIZE"
lines.append(f" Objective: {sense_str}")

n_cont = 0
n_int = 0
n_semi = 0
for v in self.vars:
t = v.VariableType
if isinstance(t, (bytes, bytearray)):
t = t.decode()
if t in ("I", VType.INTEGER):
n_int += 1
elif t in ("S", VType.SEMI_CONTINUOUS):
n_semi += 1
else:
n_cont += 1
lines.append(
f" Variables: {len(self.vars)} "
f"(continuous={n_cont}, integer={n_int}, "
f"semi-continuous={n_semi})"
)

n_linear = sum(1 for c in self.constrs if not c.is_quadratic)
n_quad = sum(1 for c in self.constrs if c.is_quadratic)
lines.append(
f" Constraints: {len(self.constrs)} "
f"(linear={n_linear}, quadratic={n_quad})"
)
lines.append(f" Non-zeros: {self.NumNZs}")

if self.solved:
status = self.Status
if hasattr(status, "name"):
status = status.name
lines.append(f" Status: {status}")
lines.append(f" Objective value: {self.ObjValue}")

return "\n".join(lines)
126 changes: 126 additions & 0 deletions python/cuopt/cuopt/tests/linear_programming/test_python_API.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,3 +826,129 @@ def test_quadratic_matrix_2():
assert x2.getValue() == pytest.approx(0.0000000, abs=1e-3)
assert x3.getValue() == pytest.approx(0.1092896, abs=1e-3)
assert problem.ObjValue == pytest.approx(3.715847, abs=1e-3)


def test_str_and_repr():
"""Verify algebraic __str__ and detailed __repr__ for LP API classes."""
prob = Problem("str_repr_test")

# === Variable ===
x = prob.addVariable(lb=0.0, ub=10.0, vtype=VType.CONTINUOUS, name="x")
y = prob.addVariable(lb=0.0, ub=5.0, vtype=VType.INTEGER, name="y")
z = prob.addVariable()

# __str__: with name returns the name
assert str(x) == "x"
assert str(y) == "y"
# __str__: without name falls back to C{index}
assert str(z) == "C2"

# __repr__: detailed summary
r = repr(x)
assert "cuopt.Variable" in r
assert "'x'" in r
assert "index=0" in r
assert "type=CONTINUOUS" in r
assert "bounds=[0.0, 10.0]" in r
assert "value=nan" in r

r = repr(y)
assert "type=INTEGER" in r
assert "bounds=[0.0, 5.0]" in r

r = repr(z)
assert "'C2'" in r
assert "index=2" in r

# === LinearExpression ===
expr1 = 2 * x + 3 * y
expr2 = expr1 - 5
expr3 = -x + 2.5

# __str__
assert str(expr1) == "2.0 * x + 3.0 * y"
assert str(expr2) == "2.0 * x + 3.0 * y - 5.0"
assert str(expr3) == "-x + 2.5"
# Empty expression collapses to 0.0
assert str(LinearExpression([], [], 0.0)) == "0.0"
# Constant-only expression
assert str(LinearExpression([], [], 3.0)) == "3.0"
assert str(LinearExpression([], [], -3.0)) == "-3.0"

# __repr__
assert repr(expr1) == "<cuopt.LinearExpression: 2.0 * x + 3.0 * y>"

# === QuadraticExpression ===
qexpr1 = x * x
qexpr2 = qexpr1 + 2 * x * y + 3 * x
qexpr3 = -x * x + 0.5 * y * y + x * y

# __str__
assert str(qexpr1) == "x^2"
assert str(qexpr2) == "x^2 + 2.0 * x * y + 3.0 * x"
assert str(qexpr3) == "-x^2 + 0.5 * y^2 + x * y"
# Empty quadratic expression
assert str(QuadraticExpression()) == "0.0"

# __repr__
assert repr(qexpr1) == "<cuopt.QuadraticExpression: x^2>"

# === Constraint ===
c1 = 2 * x + 3 * y <= 10
c2 = x - y >= 0
c3 = x + 1 == 5
prob.addConstraint(c1, name="c1")
prob.addConstraint(c2, name="c2")
prob.addConstraint(c3, name="c3")

# __str__: shows the original (un-moved) form
assert str(c1) == "2.0 * x + 3.0 * y <= 10.0"
assert str(c2) == "x - y >= 0.0"
assert str(c3) == "x + 1.0 == 5.0"

# __str__: unnamed constraint
c_anon = 2 * x + 3 * y <= 10
assert "2.0 * x + 3.0 * y <= 10.0" in str(c_anon)

# __repr__
assert repr(c1) == "<cuopt.Constraint 'c1': 2.0 * x + 3.0 * y <= 10.0>"
assert repr(c2) == "<cuopt.Constraint 'c2': x - y >= 0.0>"
assert repr(c3) == "<cuopt.Constraint 'c3': x + 1.0 == 5.0>"

# === Problem ===
# __repr__
r = repr(prob)
assert "cuopt.Problem" in r
assert "str_repr_test" in r
assert "3 vars" in r
assert "3 constrs" in r
assert "IsMIP=True" in r # y is integer

# __str__: before solve
s = str(prob)
assert "str_repr_test" in s
assert "MINIMIZE" in s
assert "Variables: 3" in s
assert "continuous=2" in s
assert "integer=1" in s
assert "semi-continuous=0" in s
assert "Constraints: 3" in s
assert "linear=3" in s
assert "quadratic=0" in s
assert "Non-zeros: 5" in s
# No status before solve
assert "Status:" not in s
assert "Objective value:" not in s

# __str__: after solve includes status and objective value
settings = SolverSettings()
settings.set_parameter("time_limit", 5)
prob.solve(settings)
s = str(prob)
assert "Status: Optimal" in s
assert "Objective value:" in s

# Unnamed problem
empty_prob = Problem()
assert "Problem: <unnamed>" in str(empty_prob)
assert "<cuopt.Problem '<unnamed>'" in repr(empty_prob)