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
246 changes: 245 additions & 1 deletion python/cuopt/cuopt/linear_programming/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ class CType(str, Enum):
GE = "G"
EQ = "E"

@property
def symbol(self):
"""Algebraic symbol used when printing constraints."""
return {CType.LE: "<=", CType.GE: ">=", CType.EQ: "=="}[self]

Comment on lines +51 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -A4 -B2 \
  'def symbol|def __str__|def __repr__' \
  python/cuopt/cuopt/linear_programming/problem.py

Repository: NVIDIA/cuopt

Length of output: 2928


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=python/cuopt/cuopt/linear_programming/problem.py

printf '%s\n' '--- targeted diff ---'
git diff --unified=12 -- "$file" | sed -n \
  '/CType/,/^[^+ -].*$/p' | head -n 220

printf '%s\n' '--- class and method definitions ---'
rg -n '^(class (CType|Variable|Constraint)|    def (symbol|__str__|__repr__))' "$file"

printf '%s\n' '--- surrounding source ---'
sed -n '35,65p;315,375p;1535,1590p' "$file"

Repository: NVIDIA/cuopt

Length of output: 5668


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("python/cuopt/cuopt/linear_programming/problem.py")
tree = ast.parse(path.read_text())

wanted = {"CType", "Variable", "Constraint"}
for node in tree.body:
    if isinstance(node, ast.ClassDef) and node.name in wanted:
        print(f"class {node.name}:")
        for item in node.body:
            if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                if item.name in {"symbol", "__str__", "__repr__"}:
                    returns = ast.unparse(item.returns) if item.returns else "<missing>"
                    doc = ast.get_docstring(item, clean=False)
                    print(
                        f"  {item.name}: return={returns}; "
                        f"docstring={'yes' if doc else 'no'}"
                    )
                    if doc:
                        print(f"    {doc!r}")
PY

Repository: NVIDIA/cuopt

Length of output: 415


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=python/cuopt/cuopt/linear_programming/problem.py

printf '%s\n' '--- repository state ---'
git status --short
git log -1 --oneline
git diff --stat
git show --stat --oneline HEAD

printf '%s\n' '--- commit-level changes in target file ---'
git show --format= --unified=3 HEAD -- "$file" | sed -n '1,260p'

printf '%s\n' '--- method blocks and immediate docstrings ---'
python3 - "$file" <<'PY'
from pathlib import Path
import re
import sys

text = Path(sys.argv[1]).read_text().splitlines()
targets = {"symbol", "__str__", "__repr__"}
class_name = None
for i, line in enumerate(text):
    class_match = re.match(r"class\s+([A-Za-z_]\w*)\b", line)
    if class_match:
        class_name = class_match.group(1)
    method_match = re.match(r"    def\s+([A-Za-z_]\w*)\s*\((.*?)\)\s*(?:->\s*([^:]+))?:", line)
    if method_match and method_match.group(1) in targets:
        name, args, ret = method_match.groups()
        print(f"{class_name}.{name}: return={ret or '<missing>'}, line={i + 1}")
        j = i + 1
        while j < len(text) and text[j].strip() == "":
            j += 1
        if j < len(text) and text[j].lstrip().startswith(('"""', "'''")):
            print(f"  docstring starts line {j + 1}: {text[j].strip()}")
        else:
            print("  docstring: <missing>")
PY

Repository: NVIDIA/cuopt

Length of output: 11182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=python/cuopt/cuopt/linear_programming/problem.py

printf '%s\n' '--- parent definitions ---'
git show HEAD^:"$file" | sed -n '35,65p;435,485p;1510,1570p'

printf '%s\n' '--- exact changed definitions ---'
git diff --unified=0 HEAD^ HEAD -- "$file" | rg -n -A18 -B4 \
  'symbol|def __str__|def __repr__|^@@'

printf '%s\n' '--- annotations and Returns sections in public APIs ---'
rg -n -U \
  '^[ ]{4}(def|async def) [A-Za-z_][A-Za-z0-9_]*\([^\\n]*\)( -> [^:]+)?:\\n|^[ ]{4}.*Returns?:' \
  "$file" | head -n 160

Repository: NVIDIA/cuopt

Length of output: 10805


Annotate the new CType.symbol property.

Add -> str and document the returned algebraic constraint symbol. The other display methods already existed.

📍 Affects 1 file
  • python/cuopt/cuopt/linear_programming/problem.py#L51-L55 (this comment)
  • python/cuopt/cuopt/linear_programming/problem.py#L343-L359
  • python/cuopt/cuopt/linear_programming/problem.py#L1559-L1579
🤖 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` around lines 51 - 55,
Update the CType.symbol property in
python/cuopt/cuopt/linear_programming/problem.py at lines 51-55 to add a str
return annotation and document that it returns the algebraic constraint symbol;
apply the same annotation/documentation change to the sibling display methods at
lines 343-359 and 1559-1579 as requested.

Sources: Coding guidelines, Path instructions


LE = CType.LE
GE = CType.GE
Expand Down Expand Up @@ -335,6 +340,131 @@ def __eq__(self, other):
case _:
raise ValueError("Unsupported operation")

def _display_name(self):
if self.VariableName:
return self.VariableName
if self.index >= 0:
# Same name an unnamed variable gets on export.
return f"C{self.index}"

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 meant to be f"V{self.index}"?

@jackthepunished jackthepunished Aug 17, 2026

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.

C is deliberate rather than a typo: Problem._to_data_model already names unnamed variables C{index} on export (and unnamed rows R{index}), following the MPS column/row convention, so str(var) lines up with what you see in an exported MPS file or in a solution. Happy to switch to V{index} if you'd rather printing be independent of the export naming, say which you prefer and I'll change it.

return ""

def __str__(self):
return self._display_name() or repr(self)

def __repr__(self):
vtype = self.VariableType
if isinstance(vtype, (bytes, bytearray)):
# VariableType is not normalized, see #1736.
vtype = vtype.decode()
return (
f"<cuopt.Variable {self._display_name()!r} (index={self.index}), "
f"type={VType(vtype).name}, bounds=[{self.LB}, {self.UB}], "
f"value={self.Value}>"
)


# Maximum number of terms rendered when stringifying a linear or quadratic
# expression. Beyond this, the head is shown followed by a ``... (N more
# terms)`` marker so that printing a model with thousands of terms stays
# readable in a REPL or notebook instead of flooding the output. Set to
# ``None`` to disable truncation entirely.
_MAX_DISPLAY_TERMS = 10


def _same_variable(var1, var2):
"""Identity/index comparison; Variable.__eq__ builds a Constraint."""
return var1 is var2 or (var1.index >= 0 and var1.index == var2.index)


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

When ``max_terms`` is set, only the first ``max_terms`` non-zero terms
are rendered; any remaining terms are counted and summarized as a
trailing ``... (N more terms)`` marker. This keeps the output bounded
for expressions with very many terms. ``max_terms=None`` (the default)
renders every term.
"""

def __init__(self, max_terms=None):
self.parts = []
self.max_terms = max_terms
# Non-zero terms seen so far (rendered + hidden).
self.n_terms = 0
# Non-zero terms omitted because the cap was reached.
self.n_hidden = 0

def add_linear(self, coef, var):
"""Add a linear term ``coef * var``."""
if coef == 0.0:
return
var_str = str(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 = str(var1)
v2_str = str(var2)
if _same_variable(var1, var2):
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):
self.n_terms += 1
if self.max_terms is not None and self.n_terms > self.max_terms:
# Past the cap: count the term but don't render it.
self.n_hidden += 1
return
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 and not self.n_hidden:
return "0.0"
result = "".join(self.parts)
if self.n_hidden:
plural = "term" if self.n_hidden == 1 else "terms"
marker = f"... ({self.n_hidden} more {plural})"
result = f"{result} + {marker}" if result else marker
return result


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


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

def __str__(self):
builder = _ExprBuilder(max_terms=_MAX_DISPLAY_TERMS)
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 +1429,17 @@ 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,
max_terms=_MAX_DISPLAY_TERMS,
)

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


class Constraint:
"""
Expand Down Expand Up @@ -1341,7 +1501,12 @@ def __init__(self, expr, sense, rhs, name=""):
self.rhs_value = rhs_value
self.RHS = rhs_value
self.vindex_coeff_dict = {}
self.vars = expr.vars
# expr.vars holds only the linear terms; id() because Variable
# overrides __eq__ and is unhashable.
seen = {}
for var in (*expr.vars, *expr.qvars1, *expr.qvars2, *expr.qvars):
seen.setdefault(id(var), var)
self.vars = list(seen.values())
return

self.is_quadratic = False
Expand Down Expand Up @@ -1397,6 +1562,28 @@ def compute_slack(self):

return self.RHS - lhs

def __str__(self):
# Rendered from the data the constraint stores for the solver, so
# the output is normalized: duplicate terms are merged and any
# expression constant is folded into the right-hand side.
builder = _ExprBuilder(max_terms=_MAX_DISPLAY_TERMS)
index_to_var = {v.index: v for v in self.vars}
if self.is_quadratic:
for row, col, val in zip(self.rows, self.cols, self.vals):
builder.add_quadratic(
val, index_to_var[row], index_to_var[col]
)
for idx, val in zip(self.linear_indices, self.linear_values):
builder.add_linear(val, index_to_var[idx])
else:
for idx, coeff in self.vindex_coeff_dict.items():
builder.add_linear(coeff, index_to_var[idx])
return f"{builder.build()} {CType(self.Sense).symbol} {self.RHS}"

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


class Problem:
"""
Expand Down Expand Up @@ -1760,9 +1947,15 @@ def updateConstraint(self, constr, coeffs=None, rhs=None):
)
if isinstance(coeffs, dict):
coeffs = coeffs.items()
new_vars = []
for var, coeff in coeffs:
idx = var.index
if idx not in constr.vindex_coeff_dict:
new_vars.append(var)
constr.vindex_coeff_dict[idx] = coeff
if new_vars:
# constr.vars aliases the expression's list; rebind it.
constr.vars = constr.vars + new_vars
if rhs is not None:
constr.RHS = rhs
else:
Expand Down Expand Up @@ -2223,3 +2416,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)
Loading