From 84c88deffe66b92d797a5884588cdfe3cb223754 Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Tue, 18 Aug 2026 09:49:22 +0300 Subject: [PATCH 1/5] Track all row variables in Constraint.vars Quadratic rows stored only the variables of their linear terms, and updateConstraint did not record variables it introduced, so mapping a row's column indices back to Variables (compute_slack) could KeyError. Signed-off-by: jackthepunished --- .../cuopt/cuopt/linear_programming/problem.py | 13 ++++++- .../linear_programming/test_python_API.py | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index 49885acf52..b9cf0096d2 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -1341,7 +1341,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 @@ -1760,9 +1765,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: diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 964809553f..076ec80eb4 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -160,6 +160,43 @@ def test_constraint_duplicate_terms_slack(): assert c.compute_slack() == pytest.approx(6.0) +def test_updateConstraint_tracks_new_variables(): + """Variables added via updateConstraint end up in Constraint.vars.""" + prob = Problem() + x1 = prob.addVariable(name="x1") + x2 = prob.addVariable(name="x2") + c = prob.addConstraint(2 * x1 <= 10) + assert [v.index for v in c.vars] == [0] + + prob.updateConstraint(c, coeffs=[(x2, 4.0)]) + assert [v.index for v in c.vars] == [0, 1] + x1.Value = 1.0 + x2.Value = 2.0 + assert c.compute_slack() == pytest.approx(0.0) + + +def test_updateConstraint_does_not_mutate_expression(): + """The expression a constraint was built from is left unchanged.""" + prob = Problem() + a = prob.addVariable(name="a") + b = prob.addVariable(name="b") + expr = 2 * a + c = prob.addConstraint(expr <= 10) + prob.updateConstraint(c, coeffs=[(b, 1.0)]) + assert len(expr.vars) == 1 + assert len(expr.coefficients) == 1 + + +def test_constraint_vars_includes_quadratic_only_variables(): + """Variables that appear only in quadratic terms are in Constraint.vars.""" + prob = Problem() + x = prob.addVariable(name="x") + y = prob.addVariable(name="y") + c = prob.addConstraint(x * x + 2 * x * y <= 4) + assert c.is_quadratic + assert [v.index for v in c.vars] == [0, 1] + + def test_semi_continuous_variable(): prob = Problem("Semi-continuous") x = prob.addVariable(lb=5.0, ub=10.0, vtype=SEMI_CONTINUOUS, name="x") From 4b791e207b5aa945943417c08dae5f2da50acc62 Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Sat, 6 Jun 2026 03:28:29 +0300 Subject: [PATCH 2/5] Add algebraic __str__ and detailed __repr__ to Python LP API classes Adds __str__ and __repr__ to Variable, LinearExpression, QuadraticExpression, Constraint, and Problem. Printing these objects now shows their algebraic form (e.g. '2.0 * x + 3.0 * y <= 10.0') and the REPL shows a detailed summary, improving debuggability in notebooks and interactive sessions. The change is purely additive. Signed-off-by: jackthepunished --- .../cuopt/cuopt/linear_programming/problem.py | 195 ++++++++++++++++++ .../linear_programming/test_python_API.py | 126 +++++++++++ 2 files changed, 321 insertions(+) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index b9cf0096d2..ac58f65c79 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -16,6 +16,100 @@ import warnings +# ---- Display helpers for __str__/__repr__ ---- + +_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="} +_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. @@ -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"" + ) + class QuadraticExpression: """ @@ -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"" + def _quadratic_expression_to_qcmatrix(expr, rhs): """Build QCMATRIX COO data for a quadratic row ``expr`` sense ``rhs``. @@ -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"" + class Constraint: """ @@ -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 @@ -1402,6 +1535,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 "" + return f"" + class Problem: """ @@ -2234,3 +2378,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 "" + return ( + f"" + ) + + def __str__(self): + lines = [] + name = self.Name if self.Name else "" + 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) diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 076ec80eb4..8faed406ec 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -888,3 +888,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) == "" + + # === 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) == "" + + # === 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) == "" + assert repr(c2) == "= 0.0>" + assert repr(c3) == "" + + # === 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: " in str(empty_prob) + assert "'" in repr(empty_prob) From cc130d1b15b8e9a6e9acb4ab66b6e0f46a660c69 Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Sat, 27 Jun 2026 02:49:51 +0300 Subject: [PATCH 3/5] Truncate large expressions in LP API __str__/__repr__ Linear and quadratic expressions now render only the first _MAX_DISPLAY_TERMS (10) terms followed by a "... (N more terms)" marker, so printing a model with thousands of terms stays readable in a REPL or notebook instead of flooding the output. Applies to both __str__ and __repr__ (and therefore Constraint, whose LHS is the expression); the cap is a module constant and can be set to None to disable. Also fix an import-time NameError: the module-level _SENSE_SYMBOLS table referenced LE/GE/EQ before they were defined. Re-key it by the underlying CType char codes ("L"/"G"/"E"), which CType members compare and hash equal to, so the module imports regardless of definition order. Add test_str_truncation_large_expression covering the linear/quadratic head + marker, the exactly-at-cap (no marker) and one-over (singular) boundaries, and the truncation-disabled case. Signed-off-by: jackthepunished --- .../cuopt/cuopt/linear_programming/problem.py | 54 ++++++++++++++++--- .../linear_programming/test_python_API.py | 44 +++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index ac58f65c79..fc9b9bdefc 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -18,9 +18,21 @@ # ---- Display helpers for __str__/__repr__ ---- -_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="} +# Keyed by the underlying CType char codes ("L"/"G"/"E"). CType is a +# ``(str, Enum)`` whose members compare and hash equal to these codes, so the +# lookup works whether ``Constraint.Sense`` holds a CType member or a raw +# string. Using the codes (rather than the LE/GE/EQ aliases) also keeps this +# module-level table independent of definition order. +_SENSE_SYMBOLS = {"L": "<=", "G": ">=", "E": "=="} _TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"} +# 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 _var_display_name(var): """Return the display name of a Variable.""" @@ -47,10 +59,21 @@ class _ExprBuilder: 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): + 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``.""" @@ -90,20 +113,30 @@ def add_constant(self, value): 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: + if not self.parts and not self.n_hidden: return "0.0" - return "".join(self.parts) + 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): +def _format_linear(vars, coeffs, constant, max_terms=None): """Format a linear expression as an algebraic string.""" - builder = _ExprBuilder() + builder = _ExprBuilder(max_terms=max_terms) for var, coef in zip(vars, coeffs): builder.add_linear(coef, var) builder.add_constant(constant) @@ -997,7 +1030,7 @@ def __eq__(self, other): raise ValueError("Equality constraints are not supported.") def __str__(self): - builder = _ExprBuilder() + 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 @@ -1407,7 +1440,12 @@ def __eq__(self, other): return Constraint(expr, EQ, 0.0) def __str__(self): - return _format_linear(self.vars, self.coefficients, self.constant) + return _format_linear( + self.vars, + self.coefficients, + self.constant, + max_terms=_MAX_DISPLAY_TERMS, + ) def __repr__(self): return f"" diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 8faed406ec..d9478a112a 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -1014,3 +1014,47 @@ def test_str_and_repr(): empty_prob = Problem() assert "Problem: " in str(empty_prob) assert "'" in repr(empty_prob) + + +def test_str_truncation_large_expression(): + """Large expressions truncate so a model with thousands of terms stays + readable in a REPL or notebook instead of flooding the output.""" + from cuopt.linear_programming.problem import _MAX_DISPLAY_TERMS + + cap = _MAX_DISPLAY_TERMS + n = cap * 3 # comfortably over the cap + + prob = Problem("trunc_test") + xs = [prob.addVariable(name=f"x{i}") for i in range(n)] + + # === LinearExpression: head is rendered, tail is summarized === + expr = 1 * xs[0] + for i in range(1, n): + expr = expr + (i + 1) * xs[i] + s = str(expr) + # Exactly `cap` terms rendered (all positive -> all " + " separators), + # plus the trailing marker joined with one more " + ". + assert s.count(" + ") == cap + assert s.startswith("x0 + ") + assert s.endswith(f"... ({n - cap} more terms)") + # repr wraps the same (truncated) string. + assert repr(expr) == f"" + + # === Exactly at the cap: no truncation === + at_cap = 1 * xs[0] + for i in range(1, cap): + at_cap = at_cap + xs[i] + assert "more terms" not in str(at_cap) + + # === One over the cap: singular "term" wording === + over = at_cap + xs[cap] + assert str(over).endswith("... (1 more term)") + + # === QuadraticExpression also truncates === + qexpr = xs[0] * xs[0] + for i in range(1, n): + qexpr = qexpr + xs[i] * xs[i] + qs = str(qexpr) + assert qs.count("^2") <= cap + assert qs.endswith(f"... ({n - cap} more terms)") + assert repr(qexpr) == f"" From b05e90311853660ad20222c7c4e2204dc0ebd1b7 Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Sat, 1 Aug 2026 16:41:21 +0300 Subject: [PATCH 4/5] Address review: move display helpers, use enum members, drop stored expr - Replace _SENSE_SYMBOLS with a CType.symbol property and _TYPE_NAMES with VType(...).name lookups. - Fold _var_display_name into Variable.__str__ and move the remaining display helpers (_MAX_DISPLAY_TERMS, _ExprBuilder, _format_linear) next to the expression classes that use them. - Use direct .index access instead of getattr; the attribute is always set in Variable.__init__ (-1 until addVariable assigns it). - Stop storing the expression on Constraint; __str__ now renders lazily from the solver data the constraint already holds, so constraints print in normalized form (duplicates merged, constants folded into the RHS) and stay in sync after updateConstraint. Quadratic rows now record all participating variables so QCMATRIX indices map to names. - Tests: add missing LinearExpression import, tighten the quadratic truncation assertion, and cover quadratic/duplicate/updateConstraint constraint display. --- .../cuopt/cuopt/linear_programming/problem.py | 270 +++++++++--------- .../linear_programming/test_python_API.py | 27 +- 2 files changed, 154 insertions(+), 143 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index fc9b9bdefc..dd5f355776 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -16,133 +16,6 @@ import warnings -# ---- Display helpers for __str__/__repr__ ---- - -# Keyed by the underlying CType char codes ("L"/"G"/"E"). CType is a -# ``(str, Enum)`` whose members compare and hash equal to these codes, so the -# lookup works whether ``Constraint.Sense`` holds a CType member or a raw -# string. Using the codes (rather than the LE/GE/EQ aliases) also keeps this -# module-level table independent of definition order. -_SENSE_SYMBOLS = {"L": "<=", "G": ">=", "E": "=="} -_TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"} - -# 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 _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'. - - 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 = _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): - 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 VType(str, Enum): """ The type of a variable is continuous, integer, or semi-continuous. @@ -175,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] + LE = CType.LE GE = CType.GE @@ -463,19 +341,123 @@ def __eq__(self, other): raise ValueError("Unsupported operation") def __str__(self): - return _var_display_name(self) + if self.VariableName: + return self.VariableName + if self.index >= 0: + return f"C{self.index}" + # Not yet added to a problem: no name and no index to show. + return f"V{id(self)}" def __repr__(self): - name = _var_display_name(self) - idx = getattr(self, "index", -1) - type_str = _type_display(self.VariableType) + vtype = self.VariableType + if isinstance(vtype, (bytes, bytearray)): + # The MPS data model yields variable types as byte codes. + vtype = vtype.decode() return ( - f"" ) +# 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 + + +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 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): + 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: """ QuadraticExpressions contain quadratic terms, linear terms, and a constant. @@ -1492,7 +1474,6 @@ 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 @@ -1574,11 +1555,22 @@ 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}" + # 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 "" diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index d9478a112a..2632c6c01d 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -18,6 +18,7 @@ MINIMIZE, SEMI_CONTINUOUS, CType, + LinearExpression, Problem, VType, sense, @@ -963,19 +964,36 @@ def test_str_and_repr(): prob.addConstraint(c2, name="c2") prob.addConstraint(c3, name="c3") - # __str__: shows the original (un-moved) form + # __str__: shows the normalized form the solver holds (duplicate terms + # merged, expression constants folded into the right-hand side) 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" + assert str(c3) == "x == 4.0" # __str__: unnamed constraint c_anon = 2 * x + 3 * y <= 10 assert "2.0 * x + 3.0 * y <= 10.0" in str(c_anon) + # __str__: duplicate terms are merged + c_dup = 2 * x + 3 * x <= 5 + assert str(c_dup) == "5.0 * x <= 5.0" + + # __str__: quadratic constraint rendered from its QCMATRIX data + c_quad = x * x + 2 * x * y <= 4 + assert str(c_quad) == "x^2 + 2.0 * x * y <= 4.0" + + # __str__: reflects updateConstraint (no stale expression data) + prob_upd = Problem("upd_test") + a = prob_upd.addVariable(name="a") + b = prob_upd.addVariable(name="b") + c_upd = prob_upd.addConstraint(2 * a + 3 * b <= 10, name="c_upd") + prob_upd.updateConstraint(c_upd, coeffs=[(a, 7.0)], rhs=20.0) + assert str(c_upd) == "7.0 * a + 3.0 * b <= 20.0" + # __repr__ assert repr(c1) == "" assert repr(c2) == "= 0.0>" - assert repr(c3) == "" + assert repr(c3) == "" # === Problem === # __repr__ @@ -1055,6 +1073,7 @@ def test_str_truncation_large_expression(): for i in range(1, n): qexpr = qexpr + xs[i] * xs[i] qs = str(qexpr) - assert qs.count("^2") <= cap + assert qs.count("^2") == cap + assert qs.startswith("x0^2 + ") assert qs.endswith(f"... ({n - cap} more terms)") assert repr(qexpr) == f"" From 55b66b71a6fc2c4b4e54cfd4f2d00dbb9c5207d6 Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Tue, 18 Aug 2026 09:51:59 +0300 Subject: [PATCH 5/5] Address review: variable identity, str fallback, tests - Compare variables by identity/index in quadratic terms, not by name - Fall back to __repr__ for a Variable without name or index - Test updateConstraint-introduced variables and duplicate names - Move the after-solve Problem.__str__ check into its own test - Assert exact truncation strings Signed-off-by: jackthepunished --- .../cuopt/cuopt/linear_programming/problem.py | 20 ++-- .../linear_programming/test_python_API.py | 99 ++++++++++--------- 2 files changed, 65 insertions(+), 54 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index dd5f355776..cae15282c4 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -340,21 +340,24 @@ def __eq__(self, other): case _: raise ValueError("Unsupported operation") - def __str__(self): + 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}" - # Not yet added to a problem: no name and no index to show. - return f"V{id(self)}" + return "" + + def __str__(self): + return self._display_name() or repr(self) def __repr__(self): vtype = self.VariableType if isinstance(vtype, (bytes, bytearray)): - # The MPS data model yields variable types as byte codes. + # VariableType is not normalized, see #1736. vtype = vtype.decode() return ( - f"" ) @@ -368,6 +371,11 @@ def __repr__(self): _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. @@ -408,7 +416,7 @@ def add_quadratic(self, coef, var1, var2): return v1_str = str(var1) v2_str = str(var2) - if v1_str == v2_str: + if _same_variable(var1, var2): term_str = f"{v1_str}^2" elif v1_str <= v2_str: term_str = f"{v1_str} * {v2_str}" diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 2632c6c01d..f701d094dc 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -20,6 +20,7 @@ CType, LinearExpression, Problem, + Variable, VType, sense, QuadraticExpression, @@ -905,6 +906,8 @@ def test_str_and_repr(): assert str(y) == "y" # __str__: without name falls back to C{index} assert str(z) == "C2" + # __str__: outside a problem there is neither name nor index + assert str(Variable()).startswith("" @@ -989,6 +999,10 @@ def test_str_and_repr(): c_upd = prob_upd.addConstraint(2 * a + 3 * b <= 10, name="c_upd") prob_upd.updateConstraint(c_upd, coeffs=[(a, 7.0)], rhs=20.0) assert str(c_upd) == "7.0 * a + 3.0 * b <= 20.0" + # __str__: variable introduced by updateConstraint + c_new = prob_upd.addConstraint(2 * a <= 10, name="c_new") + prob_upd.updateConstraint(c_new, coeffs=[(b, 4.0)]) + assert str(c_new) == "2.0 * a + 4.0 * b <= 10.0" # __repr__ assert repr(c1) == "" @@ -1020,60 +1034,49 @@ def test_str_and_repr(): assert "Status:" not in s assert "Objective value:" not in s - # __str__: after solve includes status and objective value + # Unnamed problem + empty_prob = Problem() + assert "Problem: " in str(empty_prob) + assert "'" in repr(empty_prob) + + +def test_problem_str_after_solve(): + """Problem.__str__ reports status and objective value once solved.""" + prob = Problem("solved") + x = prob.addVariable(lb=0.0, ub=1.0, name="x") + prob.setObjective(x, sense=MINIMIZE) 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: " in str(empty_prob) - assert "'" in repr(empty_prob) + assert f"Objective value: {prob.ObjValue}" in s def test_str_truncation_large_expression(): - """Large expressions truncate so a model with thousands of terms stays - readable in a REPL or notebook instead of flooding the output.""" - from cuopt.linear_programming.problem import _MAX_DISPLAY_TERMS - - cap = _MAX_DISPLAY_TERMS - n = cap * 3 # comfortably over the cap - - prob = Problem("trunc_test") - xs = [prob.addVariable(name=f"x{i}") for i in range(n)] - - # === LinearExpression: head is rendered, tail is summarized === - expr = 1 * xs[0] - for i in range(1, n): - expr = expr + (i + 1) * xs[i] - s = str(expr) - # Exactly `cap` terms rendered (all positive -> all " + " separators), - # plus the trailing marker joined with one more " + ". - assert s.count(" + ") == cap - assert s.startswith("x0 + ") - assert s.endswith(f"... ({n - cap} more terms)") - # repr wraps the same (truncated) string. - assert repr(expr) == f"" - - # === Exactly at the cap: no truncation === - at_cap = 1 * xs[0] - for i in range(1, cap): - at_cap = at_cap + xs[i] - assert "more terms" not in str(at_cap) - - # === One over the cap: singular "term" wording === - over = at_cap + xs[cap] - assert str(over).endswith("... (1 more term)") - - # === QuadraticExpression also truncates === + """Expressions past the display cap end in a "more terms" marker.""" + prob = Problem() + xs = [prob.addVariable(name=f"x{i}") for i in range(12)] + + expr = xs[0] + xs[1] + for x in xs[2:]: + expr = expr + x + assert str(expr) == ( + "x0 + x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + x9 + ... (2 more terms)" + ) + assert repr(expr) == f"" + + at_cap = xs[0] + xs[1] + for x in xs[2:10]: + at_cap = at_cap + x + assert str(at_cap) == "x0 + x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + x9" + assert str(at_cap + xs[10]).endswith("+ x9 + ... (1 more term)") + qexpr = xs[0] * xs[0] - for i in range(1, n): - qexpr = qexpr + xs[i] * xs[i] - qs = str(qexpr) - assert qs.count("^2") == cap - assert qs.startswith("x0^2 + ") - assert qs.endswith(f"... ({n - cap} more terms)") - assert repr(qexpr) == f"" + for x in xs[1:]: + qexpr = qexpr + x * x + assert str(qexpr) == ( + "x0^2 + x1^2 + x2^2 + x3^2 + x4^2 + x5^2 + x6^2 + x7^2 + x8^2 + x9^2" + " + ... (2 more terms)" + ) + assert repr(qexpr) == f""